mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(kuna): add cached string inventory and search (#4095)
Add cached, reference-free Kuna string inventories through existing CLI/MCP operations. Preserve exact addresses, typed facts, selected Evidence and bounded query lifecycles; validate native fields and caller-specific failures. Verified with real Linux SQLite, zlib, printf and Unicode/empty ELF targets, 194 focused regressions and all 38 CI checks. Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
23 files changed
+1632
-212
No files matched your search
+14
-3
@@ -18,7 +18,7 @@ Select it explicitly with `--provider kuna`, MCP's `open_binary.provider_id: "ku
|
||||
| `REA_KUNA_COMMAND` | none (required) | Absolute path of the `kuna` executable. |
|
||||
| `REA_KUNA_CACHE_DIR` | `~/Library/Caches/rea/kuna` (macOS), `$XDG_CACHE_HOME/rea/kuna` (Linux; `~/.cache/rea/kuna` when `XDG_CACHE_HOME` is unset or relative) | Absolute directory REA owns for project exports. Kuna's own spec cache also lives here, not in your Kuna cache. |
|
||||
| `REA_KUNA_CACHE_MAX_BYTES` | 4 GiB | Size cap; least recently used exports are evicted above it. |
|
||||
| `REA_KUNA_PROJECT_TIMEOUT_MS` | 30 minutes | Deadline for one project export. |
|
||||
| `REA_KUNA_PROJECT_TIMEOUT_MS` | 30 minutes | Deadline for whole-program inventory/export, reference and string scans. |
|
||||
| `REA_KUNA_TIMEOUT_MS` | 120 s | Deadline for one single-function decompile, and for the inventory run REA makes when the export ends without one. |
|
||||
| `REA_KUNA_JOBS` | 1 | `decompile-project --jobs`. 1 is Kuna's reference answer; more jobs finish sooner but lose callee-first prototypes, and change about 40–60% of function bodies on the measured binaries. |
|
||||
|
||||
@@ -33,6 +33,7 @@ Windows is not supported yet. Supported targets are ELF and thin Mach-O executab
|
||||
| `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 |
|
||||
| `procedure_callers`, `procedure_callees` | Call-kind edges from a lazy, body-free Kuna graph; synthetic import nodes are accepted by address |
|
||||
| `list_strings`, `search_strings` | Typed string candidates from one cached, reference-free scan; exact-address, literal and ECMAScript regex selection |
|
||||
| `xrefs` | Exact-address incoming reference sites, with forwarding aliases filtered explicitly |
|
||||
| `analyze_function` | Pseudocode and assembly from the export, plus call relationships; typed references, comments, strings and CFG remain unavailable |
|
||||
|
||||
@@ -40,7 +41,7 @@ Typed procedure references, basic blocks, raw-memory tools, annotations and type
|
||||
|
||||
## How answers are produced
|
||||
|
||||
The first text or assembly request in a session looks for this target's export in the cache. `list_documents` reads the selected path without initializing analysis. Inventory, function lookup, call relationships and xrefs do not start a C export. A data-name lookup starts it only when the address is absent from the function inventory. The export is keyed by the target's SHA-256, the Kuna executable's SHA-256, its version and `--jobs`.
|
||||
The first text or assembly request in a session looks for this target's export in the cache. `list_documents` reads the selected path without initializing analysis. Inventory, function lookup, string queries, call relationships and xrefs do not start a C export. A data-name lookup starts it only when the address is absent from the function inventory. The export is keyed by the target's SHA-256, the Kuna executable's SHA-256, its version and `--jobs`.
|
||||
|
||||
- **Warm cache:** analysis reads reuse the verified export. A fresh server verifies its file digests once, even for concurrent first requests; that cost scales with export size.
|
||||
- **Cold cache:**
|
||||
@@ -56,10 +57,20 @@ The background export belongs to the session. `close_binary`, or the end of a CL
|
||||
|
||||
The assembly label index is scanned once per export reader. Cancelling a request stops its wait without interrupting other readers; closing the session aborts and drains the shared scan before releasing its files.
|
||||
|
||||
Regex procedure search uses ECMAScript Unicode matching in a cancellable worker with a five-second matching deadline. Literal matching remains the default. Cancellation or a matching deadline leaves the session available.
|
||||
Regex procedure and string search uses ECMAScript Unicode matching in a cancellable worker with a five-second matching deadline. Literal matching remains the default. Cancellation or a matching deadline leaves the session available.
|
||||
|
||||
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` initially returns the complete dossier, which can still be large; later retained views do not remove that first transfer. Prefer the individual text tools for a focused question.
|
||||
|
||||
## 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 or oversized output is an error, not an empty search. JSON admits at most 16 MiB before parsing. 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; deterministic output-buffer failures are retained until close, while other failures are not cached.
|
||||
|
||||
Results keep exact u64 virtual addresses, decoded text, native encoding, terminator status and byte length including any terminator. Kuna scans mapped sections or segments for runs of at least five characters; unterminated fragments can include instruction bytes. `all` includes ASCII, UTF-8 and the engine's Latin-range UTF-16LE reading. Other text, including short strings, non-Latin wide text and UTF-16BE, can remain undiscovered. An empty result does not prove absence.
|
||||
|
||||
Use `search_strings` for the matching rows or `list_strings` with an exact address for that location. Literal and regex matching run in REA, preserving the existing case and ECMAScript Unicode semantics instead of substituting Kuna's different regex dialect. Procedure and string regex queries share one worker slot per session. Input projections are materialized only after admission, so queued queries retain no per-query inventory arrays. Close cancels and drains matching. Native metadata queries also share one admission slot, including references; a failed stop retains admission until cleanup is proven.
|
||||
|
||||
`raw_result.strings` reports the source JSON digest/size, scanned scope, extraction policy, complete inventory count and selected count without copying unrequested text into the answer. Omit the address from `list_strings` to retrieve the complete inventory as Evidence; normal session retention and bundle export apply, including transport-capacity errors for oversized inline replies. These operations preserve their complete-result contracts and do not add paging. Function-referenced string facets in `analyze_function` remain unavailable; call `xrefs` explicitly when references to one returned string are needed.
|
||||
|
||||
## Calls and references
|
||||
|
||||
The first call query runs `kuna decompile-graph --functions ''`, admitting no C or assembly bodies. One validated graph and forward/reverse indexes serve later call queries and existing `get_call_graph` / `trace_call_path` workflows. Numeric u64 addresses are parsed from their original JSON tokens. Only `call` edges become callers/callees; jumps and data references retain their distinct meaning. Graph counts, edge endpoints and ordering are validated before any relationship is returned. A forwarding address may be an instruction inside a function, so it is retained separately and never promoted to a function node or call edge. Reported indirect calls make affected relationship coverage partial; no target is invented for them.
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readdir, realpath } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { runKunaCommand } from "../../dist/kuna/KunaCommand.js";
|
||||
import { inspectKunaInstallation } from "../../dist/kuna/KunaInstallation.js";
|
||||
|
||||
const entries = (path) =>
|
||||
readdir(path).catch((cause) => {
|
||||
if (cause.code === "ENOENT") return [];
|
||||
throw cause;
|
||||
});
|
||||
|
||||
/** Independent native oracle; uses source hex fields rather than JS numeric addresses. */
|
||||
export const nativeKunaStrings = async ({
|
||||
executable,
|
||||
cacheRoot,
|
||||
workspace,
|
||||
binary,
|
||||
retain,
|
||||
}) => {
|
||||
const installed = await inspectKunaInstallation({
|
||||
REA_KUNA_COMMAND: executable,
|
||||
REA_KUNA_CACHE_DIR: cacheRoot,
|
||||
});
|
||||
assert.ok(installed.ok, "Native string oracle could not inspect Kuna");
|
||||
const input = await realpath(binary);
|
||||
const outcome = await runKunaCommand({
|
||||
installation: installed.value,
|
||||
arguments: [
|
||||
"strings",
|
||||
input,
|
||||
"--json",
|
||||
"--encoding",
|
||||
"all",
|
||||
"--min-length",
|
||||
"5",
|
||||
"--termination",
|
||||
"any",
|
||||
"--no-xrefs",
|
||||
],
|
||||
workspace,
|
||||
operation: "verify:kuna-native-strings",
|
||||
timeoutMs: installed.value.configuration.projectTimeoutMs,
|
||||
reportedTimeoutMs: installed.value.configuration.projectTimeoutMs,
|
||||
outputBytes: 16 * 1024 * 1024,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
if (outcome.kind === "retained") retain(outcome);
|
||||
if (outcome.kind !== "ok") throw outcome.error;
|
||||
const document = JSON.parse(outcome.snapshot.stdout.text);
|
||||
assert.equal(document.binary, input);
|
||||
assert.equal(document.count, document.strings.length);
|
||||
assert.equal(document.xrefs, false);
|
||||
return document.strings
|
||||
.map((row) => ({
|
||||
address: row.address_hex,
|
||||
value: row.text,
|
||||
string: {
|
||||
encoding: row.encoding,
|
||||
encoding_status: "observed",
|
||||
termination: row.nul_terminated ? "present_or_not_required" : "missing",
|
||||
byte_length: row.byte_length,
|
||||
},
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
const a = BigInt(left.address),
|
||||
b = BigInt(right.address);
|
||||
return a < b
|
||||
? -1
|
||||
: a > b
|
||||
? 1
|
||||
: left.string.encoding.localeCompare(right.string.encoding, "en");
|
||||
});
|
||||
};
|
||||
|
||||
/** Verify focused/complete string operations before any procedure analysis starts. */
|
||||
export const verifyKunaStrings = async ({
|
||||
call,
|
||||
cacheRoot,
|
||||
nativeStrings,
|
||||
expectedStrings = [],
|
||||
}) => {
|
||||
const expected = await nativeStrings();
|
||||
if (expectedStrings.length > 0)
|
||||
assert.ok(
|
||||
expected.length > 0,
|
||||
"Source-owned string fixture did not exercise a positive result",
|
||||
);
|
||||
const pattern =
|
||||
expected.find(({ value }) => value.length >= 8 && value.length <= 100)
|
||||
?.value ??
|
||||
expected[0]?.value ??
|
||||
"REA-empty-string-inventory";
|
||||
const selected = expected.filter(({ value }) =>
|
||||
value.toLowerCase().includes(pattern.toLowerCase()),
|
||||
);
|
||||
const before = await Promise.all([
|
||||
entries(join(cacheRoot, "projects")),
|
||||
entries(join(cacheRoot, "staging")),
|
||||
]);
|
||||
const cold = await call("search_strings", { pattern });
|
||||
assert.deepEqual(
|
||||
cold.value,
|
||||
selected,
|
||||
"String search differs from native scan",
|
||||
);
|
||||
const source = cold.structured.raw_result.strings;
|
||||
assert.equal(source.inventory_count, expected.length);
|
||||
assert.equal(source.selected_count, selected.length);
|
||||
assert.equal(source.xrefs, false);
|
||||
assert.match(source.sha256, /^[0-9a-f]{64}$/u);
|
||||
assert.ok(source.bytes > 0);
|
||||
assert.equal(cold.structured.provider.id, "kuna");
|
||||
assert.ok(cold.structured.evidence_id);
|
||||
const complete = await call("list_strings");
|
||||
assert.deepEqual(complete.value, expected);
|
||||
for (const value of expectedStrings)
|
||||
assert.ok(
|
||||
complete.value.some((row) => row.value === value),
|
||||
`Required string ${JSON.stringify(value)} was not recovered`,
|
||||
);
|
||||
assert.equal(complete.structured.raw_result.strings.sha256, source.sha256);
|
||||
const repeated = await call("search_strings", { pattern });
|
||||
assert.deepEqual(repeated.value, selected);
|
||||
let exact;
|
||||
if (selected.length > 0) {
|
||||
const address = selected[0].address;
|
||||
exact = await call("list_strings", { address });
|
||||
assert.deepEqual(
|
||||
exact.value,
|
||||
expected.filter((item) => item.address === address),
|
||||
);
|
||||
}
|
||||
const regex = await call("search_strings", {
|
||||
pattern: "^.{5,12}$",
|
||||
mode: "regex",
|
||||
case_sensitive: true,
|
||||
});
|
||||
assert.deepEqual(
|
||||
regex.value,
|
||||
expected.filter(({ value }) => /^.{5,12}$/u.test(value)),
|
||||
);
|
||||
const missing = await call("search_strings", {
|
||||
pattern: "\u0000REA-negative-string-query\u0000",
|
||||
});
|
||||
assert.deepEqual(missing.value, []);
|
||||
assert.deepEqual(
|
||||
await Promise.all([
|
||||
entries(join(cacheRoot, "projects")),
|
||||
entries(join(cacheRoot, "staging")),
|
||||
]),
|
||||
before,
|
||||
"String reads started a project export",
|
||||
);
|
||||
return {
|
||||
inventory_count: expected.length,
|
||||
selected_count: selected.length,
|
||||
cold_scan_ms: cold.ms,
|
||||
warm_search_ms: repeated.ms,
|
||||
exact_address_ms: exact?.ms ?? null,
|
||||
cold_response_bytes: cold.bytes,
|
||||
complete_response_bytes: complete.bytes,
|
||||
regex_response_bytes: regex.bytes,
|
||||
};
|
||||
};
|
||||
@@ -28,6 +28,10 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { execFileOutput } from "../dist/process/ExecFileOutput.js";
|
||||
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { verifyKunaReferences } from "./lib/kuna-reference-verifier.mjs";
|
||||
import {
|
||||
nativeKunaStrings,
|
||||
verifyKunaStrings,
|
||||
} from "./lib/kuna-string-verifier.mjs";
|
||||
import { nativeKunaIncomingSites } from "./lib/kuna-native-reference-verifier.mjs";
|
||||
import {
|
||||
assertVerifierCleanupComplete,
|
||||
@@ -78,6 +82,10 @@ let kunaVersion;
|
||||
const FIXTURE_SOURCE = String.raw`
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
static const char rea_kuna_utf8[] __attribute__((used)) = "_φ( °-°)/ REA unicode marker";
|
||||
static const unsigned short rea_kuna_wide[] __attribute__((used)) = {
|
||||
'R','E','A',' ','w','i','d','e',' ','m','a','r','k','e','r',0
|
||||
};
|
||||
#include <string.h>
|
||||
__attribute__((noinline)) unsigned rea_checksum(const unsigned char *p, size_t n) {
|
||||
unsigned h = 2166136261u;
|
||||
@@ -256,7 +264,12 @@ const quality = (text) => ({
|
||||
});
|
||||
|
||||
/** Verify Kuna on one binary and measure its cold and warm answers. */
|
||||
const verifyKuna = async (path, functions, expectedCaller) => {
|
||||
const verifyKuna = async (
|
||||
path,
|
||||
functions,
|
||||
expectedCaller,
|
||||
expectedStrings = [],
|
||||
) => {
|
||||
const sha256 = createHash("sha256")
|
||||
.update(await readFile(path))
|
||||
.digest("hex");
|
||||
@@ -264,6 +277,21 @@ const verifyKuna = async (path, functions, expectedCaller) => {
|
||||
const answers = [];
|
||||
let coldFailure;
|
||||
try {
|
||||
answers.push({
|
||||
strings: await verifyKunaStrings({
|
||||
call: cold.call,
|
||||
cacheRoot,
|
||||
expectedStrings,
|
||||
nativeStrings: () =>
|
||||
nativeKunaStrings({
|
||||
executable: kuna,
|
||||
cacheRoot,
|
||||
workspace,
|
||||
binary: path,
|
||||
retain: (owner) => retainedNative.add(owner),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const listed = await cold.call("list_procedures");
|
||||
for (const [name, address] of functions) {
|
||||
assert.ok(
|
||||
@@ -500,7 +528,10 @@ try {
|
||||
for (const binary of [built, stripped])
|
||||
results.push({
|
||||
binary: binary.slice(workspace.length + 1),
|
||||
kuna: await verifyKuna(binary, [...fixtureFunctions], fixtureMain),
|
||||
kuna: await verifyKuna(binary, [...fixtureFunctions], fixtureMain, [
|
||||
"_φ( °-°)/ REA unicode marker",
|
||||
"REA wide marker",
|
||||
]),
|
||||
});
|
||||
|
||||
// Caller-built matrix.
|
||||
|
||||
@@ -124,7 +124,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
),
|
||||
official(
|
||||
"list_strings",
|
||||
"List every provider-defined string object, or filter to one address, as address/value pairs with encoding, terminator status, and byte length when available. Hopper reads complete typed objects and retains its original display text as provider_value; long literals may span adjacent unterminated objects.",
|
||||
"List every provider-reported string object or candidate, or filter to one address, as address/value pairs with encoding, terminator status, and byte length when available. Hopper reads complete typed objects and retains its original display text as provider_value; long literals may span adjacent unterminated objects. Kuna scans mapped printable sequences of at least five characters, including unterminated fragments; references are not computed.",
|
||||
z.object({ document, address: optionalAddress }),
|
||||
),
|
||||
official(
|
||||
@@ -196,7 +196,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
),
|
||||
official(
|
||||
"search_strings",
|
||||
"Search every provider-defined string object using literal matching by default or regex when requested. Results are deterministic and complete over those defined objects, not every text sequence in artifact bytes. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, read_bytes and xrefs inspect its bytes and references. Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. Hopper regex mode uses ECMAScript Unicode syntax in a cancellable worker; a matching deadline leaves its API usable.",
|
||||
"Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, read_bytes and xrefs inspect its bytes and references. Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. Hopper regex mode uses ECMAScript Unicode syntax in a cancellable worker; a matching deadline leaves its API usable.",
|
||||
z.object(analysisSearchInput),
|
||||
),
|
||||
official(
|
||||
|
||||
@@ -6,3 +6,7 @@ export const normalizeKunaAddress = (value: unknown): string | undefined => {
|
||||
? undefined
|
||||
: `0x${BigInt(`0x${match[1]}`).toString(16)}`;
|
||||
};
|
||||
|
||||
/** Sort canonical hexadecimal addresses numerically without BigInt allocations. */
|
||||
export const compareKunaAddresses = (left: string, right: string): number =>
|
||||
left.length - right.length || (left < right ? -1 : left > right ? 1 : 0);
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Parse numeric u64 addresses from their exact JSON tokens, before rounding. */
|
||||
export const parseKunaJson = (
|
||||
text: string,
|
||||
limit: number,
|
||||
addressKeys: ReadonlySet<string>,
|
||||
): unknown => {
|
||||
if (Buffer.byteLength(text) > limit)
|
||||
throw new Error("Kuna JSON exceeds its buffered capacity");
|
||||
return JSON.parse(
|
||||
text,
|
||||
(
|
||||
key: string,
|
||||
value: unknown,
|
||||
context?: { readonly source?: string },
|
||||
): unknown => {
|
||||
if (!addressKeys.has(key) || value === null) return value;
|
||||
const source = context?.source;
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
source === undefined ||
|
||||
source.length > 20 ||
|
||||
!/^(?:0|[1-9][0-9]*)$/u.test(source)
|
||||
)
|
||||
throw new Error(`invalid numeric address in ${key}`);
|
||||
const exact = BigInt(source);
|
||||
if (exact > 0xffffffffffffffffn)
|
||||
throw new Error(`address in ${key} exceeds u64`);
|
||||
return `0x${exact.toString(16)}`;
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -1,2 +1,6 @@
|
||||
/** Shared buffered-text capacity for a subprocess answer or one cached source facet. */
|
||||
export const KUNA_FUNCTION_TEXT_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/** One buffered, retained string scan; independent of decompilation. */
|
||||
export const KUNA_STRING_OUTPUT_BYTES = 16 * 1024 * 1024;
|
||||
export const KUNA_STRING_MIN_LENGTH = 5;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { KUNA_STRING_MIN_LENGTH } from "./KunaLimits.js";
|
||||
import { accessSync, constants, statSync } from "node:fs";
|
||||
|
||||
import type {
|
||||
@@ -193,6 +194,13 @@ export class KunaProvider implements AnalysisProviderCandidate {
|
||||
edge_kind: "call",
|
||||
},
|
||||
xrefs: { command: "xrefs", direction: "to", exact_address: true },
|
||||
strings: {
|
||||
command: "strings",
|
||||
encoding: "all",
|
||||
min_length: KUNA_STRING_MIN_LENGTH,
|
||||
termination: "any",
|
||||
xrefs: false,
|
||||
},
|
||||
cache_policy: "live",
|
||||
},
|
||||
),
|
||||
|
||||
@@ -19,6 +19,8 @@ export const KUNA_OPERATIONS = Object.freeze([
|
||||
"list_documents",
|
||||
"list_procedures",
|
||||
"search_procedures",
|
||||
"list_strings",
|
||||
"search_strings",
|
||||
"procedure_address",
|
||||
"address_name",
|
||||
"procedure_pseudo_code",
|
||||
@@ -32,6 +34,9 @@ export type KunaOperation = (typeof KUNA_OPERATIONS)[number];
|
||||
|
||||
export const KUNA_RELATIONSHIP_LIMITATION =
|
||||
"Kuna reports call-kind edges between its discovered function nodes. Jump/data edges are excluded; unresolved indirect calls and targets outside that inventory remain unknown.";
|
||||
export const KUNA_STRING_LIMITATION =
|
||||
"Kuna scans its mapped sections or segments for printable runs of at least five characters, including unterminated fragments. Encodings are ASCII, UTF-8 and Latin-range UTF-16LE; other text can remain undiscovered. String scanning skips references; an empty search is not proof of absence.";
|
||||
|
||||
export const KUNA_XREF_LIMITATION =
|
||||
"Kuna's static reference walk unifies forwarding aliases and omits references internal to that alias class. REA keeps only exact-address incoming edges; an empty result does not prove no reference exists.";
|
||||
|
||||
@@ -61,6 +66,14 @@ export const kunaLimitations = (
|
||||
switch (operation) {
|
||||
case "list_documents":
|
||||
return [];
|
||||
case "list_strings":
|
||||
return [KUNA_PROVENANCE, KUNA_STRING_LIMITATION];
|
||||
case "search_strings":
|
||||
return [
|
||||
KUNA_PROVENANCE,
|
||||
KUNA_STRING_LIMITATION,
|
||||
REGEX_MATCHING_LIMITATION,
|
||||
];
|
||||
case "list_procedures":
|
||||
case "procedure_address":
|
||||
case "address_name":
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type {
|
||||
AnalysisError,
|
||||
AnalysisErrorOptions,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
|
||||
/** Keep one producer failure immutable while each waiter receives its context. */
|
||||
export const kunaQueryFailureForCaller = (
|
||||
source: AnalysisError,
|
||||
operation: string,
|
||||
): AnalysisError => {
|
||||
if (
|
||||
!("operation" in source) ||
|
||||
typeof source.operation !== "string" ||
|
||||
source.operation === operation
|
||||
)
|
||||
return source;
|
||||
const diagnostics = {
|
||||
...source.diagnostics,
|
||||
producer_operation: source.operation,
|
||||
};
|
||||
const options: AnalysisErrorOptions & {
|
||||
userMessage?: string;
|
||||
remediationAction?: string;
|
||||
} = {
|
||||
cause: source,
|
||||
diagnostics,
|
||||
...(source.capturedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: source.capturedOutput }),
|
||||
...(source.cleanup === undefined ? {} : { cleanup: source.cleanup }),
|
||||
...(source.partialObservation === undefined
|
||||
? {}
|
||||
: { partialObservation: source.partialObservation }),
|
||||
...(source.userMessage === undefined
|
||||
? {}
|
||||
: { userMessage: source.userMessage }),
|
||||
...("remediationAction" in source &&
|
||||
typeof source.remediationAction === "string"
|
||||
? { remediationAction: source.remediationAction }
|
||||
: {}),
|
||||
};
|
||||
// The command owner keeps the original cleanup failure and its supervisor.
|
||||
// This contextual diagnostic retains that source as its cause and every resource.
|
||||
if (source instanceof ProviderCleanupError)
|
||||
return new ProviderCleanupError(
|
||||
source.providerId,
|
||||
source.cleanupResources,
|
||||
diagnostics,
|
||||
{
|
||||
operation,
|
||||
cause: source,
|
||||
...(source.partialObservation === undefined
|
||||
? {}
|
||||
: { partialObservation: source.partialObservation }),
|
||||
},
|
||||
);
|
||||
if (source instanceof AnalysisTimeoutError)
|
||||
return new AnalysisTimeoutError(operation, source.timeoutMs, options);
|
||||
if (source instanceof AnalysisResourceConstraintError)
|
||||
return new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
source.resource,
|
||||
source.reason,
|
||||
source.reportedLimits,
|
||||
options,
|
||||
);
|
||||
if (source instanceof AnalysisOutputError)
|
||||
return new AnalysisOutputError(operation, source.reason, {
|
||||
...options,
|
||||
cleanupOnly: source.cleanupOnly,
|
||||
});
|
||||
if (source instanceof ProviderAdapterError)
|
||||
return new ProviderAdapterError(source.providerId, operation, options);
|
||||
if (source instanceof AnalysisCancelledError)
|
||||
return new AnalysisCancelledError(operation, options);
|
||||
if (source instanceof AnalysisArtifactChangedError)
|
||||
return new AnalysisArtifactChangedError(
|
||||
operation,
|
||||
source.path,
|
||||
source.reason,
|
||||
options,
|
||||
);
|
||||
// Preserve unfamiliar error variants rather than flattening their semantics.
|
||||
return source;
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js";
|
||||
import { matchRegexValues } from "../process/RegexMatcher.js";
|
||||
import { KunaRunSlots } from "./KunaRunSlots.js";
|
||||
import type {
|
||||
KunaTargetCommands,
|
||||
KunaTargetCommandRequest,
|
||||
KunaTargetCommandSnapshot,
|
||||
} from "./KunaTargetCommands.js";
|
||||
|
||||
/** One shared producer; individual callers own only their wait. */
|
||||
export interface KunaPendingQuery<T> {
|
||||
readonly promise: Promise<Result<T, AnalysisError>>;
|
||||
readonly controller: AbortController;
|
||||
waiters: number;
|
||||
settled: boolean;
|
||||
}
|
||||
|
||||
type RegexQuery = Omit<Parameters<typeof matchRegexValues>[0], "values"> & {
|
||||
readonly values: () => readonly string[];
|
||||
};
|
||||
|
||||
/** Shared admission, cancellation and cleanup for session metadata queries. */
|
||||
export class KunaQueryWork {
|
||||
readonly #nativeSlots = new KunaRunSlots(1);
|
||||
readonly #regexSlots = new KunaRunSlots(1);
|
||||
|
||||
constructor(
|
||||
private readonly commands: KunaTargetCommands,
|
||||
private readonly lifetime: {
|
||||
readonly signal: AbortSignal;
|
||||
readonly own: <T>(work: Promise<T>) => Promise<T>;
|
||||
readonly runSlots: KunaRunSlots;
|
||||
},
|
||||
) {}
|
||||
|
||||
/** Unproven process cleanup retains both leases in the native command owner. */
|
||||
async run(
|
||||
request: Omit<KunaTargetCommandRequest, "releaseRun">,
|
||||
): Promise<Result<KunaTargetCommandSnapshot, AnalysisError>> {
|
||||
const local = await this.#nativeSlots.acquire(request.signal);
|
||||
if (local === undefined)
|
||||
return err(new AnalysisCancelledError(request.operation));
|
||||
let release: (() => void) | undefined;
|
||||
try {
|
||||
const foreground = await this.lifetime.runSlots.acquire(request.signal);
|
||||
if (foreground === undefined) {
|
||||
local();
|
||||
return err(new AnalysisCancelledError(request.operation));
|
||||
}
|
||||
release = () => {
|
||||
foreground();
|
||||
local();
|
||||
};
|
||||
return await this.commands.run({ ...request, releaseRun: release });
|
||||
} catch (cause: unknown) {
|
||||
if (release === undefined) local();
|
||||
else release();
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bound inventory copies and regex workers; close cancels and drains both. */
|
||||
match(input: RegexQuery): ReturnType<typeof matchRegexValues> {
|
||||
const signal = AbortSignal.any([
|
||||
this.lifetime.signal,
|
||||
...(input.signal === undefined ? [] : [input.signal]),
|
||||
]);
|
||||
return this.lifetime.own(this.#match(input, signal));
|
||||
}
|
||||
|
||||
async #match(
|
||||
input: RegexQuery,
|
||||
signal: AbortSignal,
|
||||
): ReturnType<typeof matchRegexValues> {
|
||||
const release = await this.#regexSlots.acquire(signal);
|
||||
if (release === undefined)
|
||||
return err(new AnalysisCancelledError(input.operation));
|
||||
try {
|
||||
return await matchRegexValues({
|
||||
...input,
|
||||
values: input.values(),
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
start<T>(
|
||||
load: (signal: AbortSignal) => Promise<Result<T, AnalysisError>>,
|
||||
accept: (value: T) => void,
|
||||
): KunaPendingQuery<T> {
|
||||
const controller = new AbortController();
|
||||
const signal = AbortSignal.any([this.lifetime.signal, controller.signal]);
|
||||
const pending: KunaPendingQuery<T> = {
|
||||
promise: this.lifetime.own(load(signal)),
|
||||
controller,
|
||||
settled: false,
|
||||
waiters: 0,
|
||||
};
|
||||
void pending.promise.then(
|
||||
(result) => {
|
||||
pending.settled = true;
|
||||
if (result.ok && !signal.aborted) accept(result.value);
|
||||
},
|
||||
() => {
|
||||
pending.settled = true;
|
||||
},
|
||||
);
|
||||
return pending;
|
||||
}
|
||||
|
||||
async wait<T>(
|
||||
pending: KunaPendingQuery<T>,
|
||||
signal: AbortSignal | undefined,
|
||||
operation: string,
|
||||
): Promise<Result<T, AnalysisError>> {
|
||||
pending.waiters += 1;
|
||||
try {
|
||||
const result = await waitForAbortable(pending.promise, signal);
|
||||
if (result === ABORTED && pending.waiters === 1 && !pending.settled) {
|
||||
pending.controller.abort();
|
||||
const stopped = await pending.promise;
|
||||
if (!stopped.ok && !(stopped.error instanceof AnalysisCancelledError))
|
||||
return stopped;
|
||||
}
|
||||
return result === ABORTED
|
||||
? err(new AnalysisCancelledError(operation))
|
||||
: result;
|
||||
} finally {
|
||||
pending.waiters -= 1;
|
||||
if (pending.waiters === 0 && !pending.settled) pending.controller.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { compareKunaAddresses } from "./KunaAddress.js";
|
||||
import { parseKunaJson } from "./KunaJson.js";
|
||||
|
||||
/** Raw JSON budgets also bound the materialized indexes; no decompiled bodies are admitted. */
|
||||
export const KUNA_GRAPH_OUTPUT_BYTES = 32 * 1024 * 1024;
|
||||
@@ -20,34 +22,6 @@ const address = z.string().regex(/^0x(?:0|[1-9a-f][0-9a-f]{0,15})$/u);
|
||||
const count = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
||||
const kind = z.enum(["call", "jump", "data", "read", "write"]);
|
||||
|
||||
/** JSON's numeric u64 addresses must never pass through rounded JS Numbers. */
|
||||
const parse = (text: string, limit: number): unknown => {
|
||||
if (Buffer.byteLength(text) > limit)
|
||||
throw new Error("reference JSON exceeds its buffered capacity");
|
||||
return JSON.parse(
|
||||
text,
|
||||
(
|
||||
key: string,
|
||||
value: unknown,
|
||||
context?: { readonly source?: string },
|
||||
): unknown => {
|
||||
if (!addressKeys.has(key) || value === null) return value;
|
||||
const source = context?.source;
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
source === undefined ||
|
||||
source.length > 20 ||
|
||||
!/^(?:0|[1-9][0-9]*)$/u.test(source)
|
||||
)
|
||||
throw new Error(`invalid numeric address in ${key}`);
|
||||
const exact = BigInt(source);
|
||||
if (exact > 0xffffffffffffffffn)
|
||||
throw new Error(`address in ${key} exceeds u64`);
|
||||
return `0x${exact.toString(16)}`;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const graphHeader = z.object({
|
||||
schemaVersion: z.literal(4),
|
||||
binary: z.object({
|
||||
@@ -86,17 +60,15 @@ export interface KunaCallGraph {
|
||||
readonly callees: ReadonlyMap<string, readonly string[]>;
|
||||
}
|
||||
|
||||
/** Canonical hexadecimal addresses sort numerically without repeated BigInt allocations. */
|
||||
export const compareKunaAddresses = (left: string, right: string): number =>
|
||||
left.length - right.length || (left < right ? -1 : left > right ? 1 : 0);
|
||||
|
||||
/** Validate once at the producer boundary and retain only facts used by queries. */
|
||||
export const parseKunaCallGraph = (
|
||||
text: string,
|
||||
input: string,
|
||||
): Result<KunaCallGraph, string> => {
|
||||
try {
|
||||
const header = graphHeader.parse(parse(text, KUNA_GRAPH_OUTPUT_BYTES));
|
||||
const header = graphHeader.parse(
|
||||
parseKunaJson(text, KUNA_GRAPH_OUTPUT_BYTES, addressKeys),
|
||||
);
|
||||
if (header.binary.sourcePath !== input)
|
||||
throw new Error("graph names a different input");
|
||||
if (
|
||||
@@ -202,7 +174,9 @@ export const parseKunaIncomingReferences = (
|
||||
target: string,
|
||||
): Result<KunaIncomingReferences, string> => {
|
||||
try {
|
||||
const document = xrefsDocument.parse(parse(text, KUNA_XREF_OUTPUT_BYTES));
|
||||
const document = xrefsDocument.parse(
|
||||
parseKunaJson(text, KUNA_XREF_OUTPUT_BYTES, addressKeys),
|
||||
);
|
||||
if (
|
||||
document.binary !== input ||
|
||||
document.target.address !== target ||
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { FunctionDossier } from "../domain/hopperValues.js";
|
||||
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js";
|
||||
import type { KunaInstallation } from "./KunaInstallation.js";
|
||||
import {
|
||||
KUNA_GRAPH_OUTPUT_BYTES,
|
||||
@@ -21,12 +20,7 @@ import {
|
||||
type KunaIncomingReferences,
|
||||
} from "./KunaReferenceFiles.js";
|
||||
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
|
||||
import type {
|
||||
KunaTargetCommands,
|
||||
KunaTargetCommandRequest,
|
||||
KunaTargetCommandSnapshot,
|
||||
} from "./KunaTargetCommands.js";
|
||||
import { KunaRunSlots } from "./KunaRunSlots.js";
|
||||
import type { KunaPendingQuery, KunaQueryWork } from "./KunaQueryWork.js";
|
||||
|
||||
/** Charge raw JSON bytes conservatively; retained projections are smaller. */
|
||||
const XREF_CACHE_BYTES = 16 * 1024 * 1024;
|
||||
@@ -34,13 +28,6 @@ const XREF_CACHE_BYTES = 16 * 1024 * 1024;
|
||||
const XREF_CACHE_ENTRIES = 128;
|
||||
const PENDING_XREF_QUERIES = 8;
|
||||
|
||||
interface Pending<T> {
|
||||
readonly promise: Promise<Result<T, AnalysisError>>;
|
||||
readonly controller: AbortController;
|
||||
waiters: number;
|
||||
settled: boolean;
|
||||
}
|
||||
|
||||
export interface KunaFunctionRelationships {
|
||||
readonly callers: readonly FunctionDossier["callers"][number][];
|
||||
readonly callees: readonly FunctionDossier["callees"][number][];
|
||||
@@ -52,26 +39,16 @@ export interface KunaFunctionRelationships {
|
||||
/** Lazy session facts, shared producers and bounded exact-address xref reuse. */
|
||||
export class KunaReferenceQueries {
|
||||
#graph: KunaCallGraph | undefined;
|
||||
/**
|
||||
* A graph that overflowed REA's fixed output buffer. The same target and
|
||||
* executable produce the same graph, so later queries report this failure
|
||||
* instead of repeating the whole-program walk.
|
||||
*/
|
||||
/** Deterministic buffer failures belong to the same session as its graph. */
|
||||
#graphFailure: AnalysisError | undefined;
|
||||
#pendingGraph: Pending<KunaCallGraph> | undefined;
|
||||
#pendingGraph: KunaPendingQuery<KunaCallGraph> | undefined;
|
||||
readonly #xrefs = new Map<string, KunaIncomingReferences>();
|
||||
readonly #pendingXrefs = new Map<string, Pending<KunaIncomingReferences>>();
|
||||
readonly #pendingXrefs = new Map<
|
||||
string,
|
||||
KunaPendingQuery<KunaIncomingReferences>
|
||||
>();
|
||||
#xrefBytes = 0;
|
||||
readonly #nativeSlots = new KunaRunSlots(1);
|
||||
|
||||
constructor(
|
||||
private readonly commands: KunaTargetCommands,
|
||||
private readonly lifetime: {
|
||||
readonly signal: AbortSignal;
|
||||
readonly own: <T>(work: Promise<T>) => Promise<T>;
|
||||
readonly runSlots: KunaRunSlots;
|
||||
},
|
||||
) {}
|
||||
constructor(private readonly work: KunaQueryWork) {}
|
||||
|
||||
async relationships(
|
||||
installation: KunaInstallation,
|
||||
@@ -118,7 +95,11 @@ export class KunaReferenceQueries {
|
||||
if (this.#graphFailure !== undefined) return err(this.#graphFailure);
|
||||
if (this.#graph === undefined) {
|
||||
if (this.#pendingGraph?.controller.signal.aborted === true) {
|
||||
const retired = await this.#wait(this.#pendingGraph, signal, operation);
|
||||
const retired = await this.work.wait(
|
||||
this.#pendingGraph,
|
||||
signal,
|
||||
operation,
|
||||
);
|
||||
if (!retired.ok && !(retired.error instanceof AnalysisCancelledError))
|
||||
return retired;
|
||||
if (signal?.aborted === true)
|
||||
@@ -126,9 +107,9 @@ export class KunaReferenceQueries {
|
||||
return this.#loadGraph(installation, operation, signal);
|
||||
}
|
||||
if (this.#pendingGraph === undefined) {
|
||||
const pending = this.#start<KunaCallGraph>(
|
||||
const pending = this.work.start<KunaCallGraph>(
|
||||
async (producerSignal) => {
|
||||
const run = await this.#run({
|
||||
const run = await this.work.run({
|
||||
installation,
|
||||
operation: "kuna:call-graph",
|
||||
arguments: (input) => [
|
||||
@@ -164,7 +145,7 @@ export class KunaReferenceQueries {
|
||||
};
|
||||
void pending.promise.then(release, release);
|
||||
}
|
||||
return this.#wait(this.#pendingGraph, signal, operation);
|
||||
return this.work.wait(this.#pendingGraph, signal, operation);
|
||||
}
|
||||
return ok(this.#graph);
|
||||
}
|
||||
@@ -255,7 +236,7 @@ export class KunaReferenceQueries {
|
||||
}
|
||||
let pending = this.#pendingXrefs.get(address);
|
||||
if (pending?.controller.signal.aborted === true) {
|
||||
const retired = await this.#wait(pending, signal, "xrefs");
|
||||
const retired = await this.work.wait(pending, signal, "xrefs");
|
||||
if (!retired.ok && !(retired.error instanceof AnalysisCancelledError))
|
||||
return retired;
|
||||
if (signal?.aborted === true)
|
||||
@@ -272,9 +253,9 @@ export class KunaReferenceQueries {
|
||||
{ limit_queries: PENDING_XREF_QUERIES },
|
||||
),
|
||||
);
|
||||
pending = this.#start<KunaIncomingReferences>(
|
||||
pending = this.work.start<KunaIncomingReferences>(
|
||||
async (producerSignal) => {
|
||||
const run = await this.#run({
|
||||
const run = await this.work.run({
|
||||
installation,
|
||||
operation: "xrefs",
|
||||
arguments: (input) => ["xrefs", input, "--to", address, "--json"],
|
||||
@@ -302,7 +283,7 @@ export class KunaReferenceQueries {
|
||||
};
|
||||
void pending.promise.then(release, release);
|
||||
}
|
||||
return this.#wait(pending, signal, "xrefs");
|
||||
return this.work.wait(pending, signal, "xrefs");
|
||||
}
|
||||
|
||||
#cache(address: string, value: KunaIncomingReferences): void {
|
||||
@@ -341,78 +322,4 @@ export class KunaReferenceQueries {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Reference walks share the provider's foreground budget and one local
|
||||
* slot. The native owner retains both leases when cleanup is unproven. */
|
||||
async #run(
|
||||
request: Omit<KunaTargetCommandRequest, "releaseRun">,
|
||||
): Promise<Result<KunaTargetCommandSnapshot, AnalysisError>> {
|
||||
const local = await this.#nativeSlots.acquire(request.signal);
|
||||
if (local === undefined)
|
||||
return err(new AnalysisCancelledError(request.operation));
|
||||
let release: (() => void) | undefined;
|
||||
try {
|
||||
const foreground = await this.lifetime.runSlots.acquire(request.signal);
|
||||
if (foreground === undefined) {
|
||||
local();
|
||||
return err(new AnalysisCancelledError(request.operation));
|
||||
}
|
||||
release = () => {
|
||||
foreground();
|
||||
local();
|
||||
};
|
||||
return await this.commands.run({ ...request, releaseRun: release });
|
||||
} catch (cause: unknown) {
|
||||
if (release === undefined) local();
|
||||
else release();
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
#start<T>(
|
||||
load: (signal: AbortSignal) => Promise<Result<T, AnalysisError>>,
|
||||
accept: (value: T) => void,
|
||||
): Pending<T> {
|
||||
const controller = new AbortController();
|
||||
const signal = AbortSignal.any([this.lifetime.signal, controller.signal]);
|
||||
const pending: Pending<T> = {
|
||||
promise: this.lifetime.own(load(signal)),
|
||||
controller,
|
||||
settled: false,
|
||||
waiters: 0,
|
||||
};
|
||||
void pending.promise.then(
|
||||
(result) => {
|
||||
pending.settled = true;
|
||||
if (result.ok && !signal.aborted) accept(result.value);
|
||||
},
|
||||
() => {
|
||||
pending.settled = true;
|
||||
},
|
||||
);
|
||||
return pending;
|
||||
}
|
||||
|
||||
async #wait<T>(
|
||||
pending: Pending<T>,
|
||||
signal: AbortSignal | undefined,
|
||||
operation: string,
|
||||
): Promise<Result<T, AnalysisError>> {
|
||||
pending.waiters += 1;
|
||||
try {
|
||||
const result = await waitForAbortable(pending.promise, signal);
|
||||
if (result === ABORTED && pending.waiters === 1 && !pending.settled) {
|
||||
pending.controller.abort();
|
||||
const stopped = await pending.promise;
|
||||
if (!stopped.ok && !(stopped.error instanceof AnalysisCancelledError))
|
||||
return stopped;
|
||||
}
|
||||
return result === ABORTED
|
||||
? err(new AnalysisCancelledError(operation))
|
||||
: result;
|
||||
} finally {
|
||||
pending.waiters -= 1;
|
||||
if (pending.waiters === 0 && !pending.settled) pending.controller.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import type { EvidenceLocation } from "../domain/evidence.js";
|
||||
import { combineFunctionRelationshipCoverage } from "../domain/functionCallCoverage.js";
|
||||
import { functionDossierSchema } from "../domain/hopperValues.js";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
|
||||
@@ -31,7 +32,6 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js";
|
||||
import { matchRegexValues } from "../process/RegexMatcher.js";
|
||||
import {
|
||||
KUNA_INVENTORY_OUTPUT_BYTES,
|
||||
type KunaProcessLauncher,
|
||||
@@ -60,6 +60,8 @@ import {
|
||||
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
|
||||
import { KunaTargetCommands } from "./KunaTargetCommands.js";
|
||||
import { KunaReferenceQueries } from "./KunaReferenceQueries.js";
|
||||
import { KunaQueryWork } from "./KunaQueryWork.js";
|
||||
import { KunaStringQueries } from "./KunaStringQueries.js";
|
||||
import { normalizeKunaAddress } from "./KunaAddress.js";
|
||||
import { KUNA_FUNCTION_TEXT_BYTES } from "./KunaLimits.js";
|
||||
import type { KunaRunRelease, KunaRunSlots } from "./KunaRunSlots.js";
|
||||
@@ -104,6 +106,7 @@ interface Answer {
|
||||
readonly locations: readonly EvidenceLocation[];
|
||||
readonly limitations: readonly string[];
|
||||
readonly referenceSource?: JsonValue;
|
||||
readonly stringSource?: JsonValue;
|
||||
readonly relationshipCoverage?: RelationshipCoverage;
|
||||
}
|
||||
|
||||
@@ -213,7 +216,9 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
/** Requests and shared readers retain their resources until their work settles. */
|
||||
readonly #active = new Set<Promise<unknown>>();
|
||||
readonly #commands: KunaTargetCommands;
|
||||
readonly #queryWork: KunaQueryWork;
|
||||
readonly #references: KunaReferenceQueries;
|
||||
readonly #strings: KunaStringQueries;
|
||||
#installation: Promise<Result<KunaInstallation, AnalysisError>> | undefined;
|
||||
#inventory:
|
||||
| Promise<Result<readonly KunaInventoryFunction[], AnalysisError>>
|
||||
@@ -257,11 +262,13 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
? {}
|
||||
: { launcher: dependencies.launcher }),
|
||||
});
|
||||
this.#references = new KunaReferenceQueries(this.#commands, {
|
||||
this.#queryWork = new KunaQueryWork(this.#commands, {
|
||||
signal: this.#session.signal,
|
||||
own: (work) => this.#own(work),
|
||||
runSlots: dependencies.runSlots,
|
||||
});
|
||||
this.#references = new KunaReferenceQueries(this.#queryWork);
|
||||
this.#strings = new KunaStringQueries(this.#queryWork);
|
||||
}
|
||||
|
||||
async execute(
|
||||
@@ -307,7 +314,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
KUNA_PROVIDER_ID,
|
||||
operation,
|
||||
"Kuna answers procedure listings, lookups, pseudocode and assembly; this operation needs another provider.",
|
||||
"Kuna answers procedure and string queries, pseudocode, assembly and references; this operation needs another provider.",
|
||||
),
|
||||
);
|
||||
const document = parameters.document;
|
||||
@@ -391,6 +398,7 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
// An admitted request may create a shared reader after shutdown begins.
|
||||
while (this.#active.size > 0) await Promise.allSettled(this.#active);
|
||||
this.#references.clear();
|
||||
this.#strings.clear();
|
||||
await this.#project;
|
||||
await this.#inventory;
|
||||
const failures: ProviderCleanupError[] = [];
|
||||
@@ -455,7 +463,9 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
{
|
||||
// Kuna's identity is in the analysis profile; the raw result records
|
||||
// only where file-backed text came from.
|
||||
...(source === null && answer.referenceSource === undefined
|
||||
...(source === null &&
|
||||
answer.referenceSource === undefined &&
|
||||
answer.stringSource === undefined
|
||||
? {}
|
||||
: {
|
||||
rawResult: {
|
||||
@@ -476,6 +486,9 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
...(answer.referenceSource === undefined
|
||||
? {}
|
||||
: { references: answer.referenceSource }),
|
||||
...(answer.stringSource === undefined
|
||||
? {}
|
||||
: { strings: answer.stringSource }),
|
||||
},
|
||||
}),
|
||||
limitations: [...operationLimitations, ...answer.limitations],
|
||||
@@ -531,39 +544,12 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
installation,
|
||||
options,
|
||||
);
|
||||
if (operation === "list_strings" || operation === "search_strings")
|
||||
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;
|
||||
if (operation === "list_procedures" || operation === "search_procedures") {
|
||||
const items = inventory.value.map(({ address, name }) => ({
|
||||
address,
|
||||
value: name,
|
||||
}));
|
||||
let selected = items;
|
||||
if (operation === "search_procedures") {
|
||||
const query = analysisSearchQuerySchema.parse(parameters);
|
||||
if (query.mode === "regex") {
|
||||
const matches = await matchRegexValues({
|
||||
operation,
|
||||
pattern: query.pattern,
|
||||
caseSensitive: query.case_sensitive,
|
||||
values: items.map(({ value }) => value),
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!matches.ok) return matches;
|
||||
const indices = new Set(matches.value);
|
||||
selected = items.filter((_, index) => indices.has(index));
|
||||
} else {
|
||||
const matches = analysisSearchPredicate(query);
|
||||
selected = items.filter(({ value }) => matches(value));
|
||||
}
|
||||
}
|
||||
return ok({
|
||||
result: selected,
|
||||
source: null,
|
||||
locations: [],
|
||||
limitations: [],
|
||||
});
|
||||
}
|
||||
if (operation === "address_name")
|
||||
return this.#addressName(
|
||||
parameters,
|
||||
@@ -642,6 +628,75 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
}
|
||||
}
|
||||
|
||||
async #stringAnswer(
|
||||
operation: "list_strings" | "search_strings",
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
installation: KunaInstallation,
|
||||
options: ExecutionOptions,
|
||||
): Promise<Result<Answer, AnalysisError>> {
|
||||
const answer = await this.#own(
|
||||
this.#strings.execute(
|
||||
operation,
|
||||
parameters,
|
||||
installation,
|
||||
AbortSignal.any([
|
||||
this.#session.signal,
|
||||
...(options.signal === undefined ? [] : [options.signal]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
return answer.ok
|
||||
? ok({
|
||||
result: answer.value.result,
|
||||
source: null,
|
||||
stringSource: answer.value.source,
|
||||
locations: [],
|
||||
limitations: [],
|
||||
})
|
||||
: answer;
|
||||
}
|
||||
|
||||
async #procedureList(
|
||||
operation: "list_procedures" | "search_procedures",
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
installation: KunaInstallation,
|
||||
options: ExecutionOptions,
|
||||
): Promise<Result<Answer, AnalysisError>> {
|
||||
const query =
|
||||
operation === "search_procedures"
|
||||
? analysisSearchQuerySchema.safeParse(parameters)
|
||||
: undefined;
|
||||
if (query !== undefined && !query.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(operation, query.error.issues, parameters),
|
||||
);
|
||||
const inventory = await this.#ensureInventory(installation, options);
|
||||
if (!inventory.ok) return inventory;
|
||||
let selected = inventory.value;
|
||||
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),
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!matches.ok) return matches;
|
||||
selected = matches.value.map((index) => inventory.value[index]!);
|
||||
} else {
|
||||
const matches = analysisSearchPredicate(query.data);
|
||||
selected = inventory.value.filter(({ name }) => matches(name));
|
||||
}
|
||||
}
|
||||
return ok({
|
||||
result: selected.map(({ address, name }) => ({ address, value: name })),
|
||||
source: null,
|
||||
locations: [],
|
||||
limitations: [],
|
||||
});
|
||||
}
|
||||
|
||||
async #referenceAnswer(
|
||||
operation: "xrefs" | "procedure_callers" | "procedure_callees",
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import type { analysisStringSchema } from "../domain/hopperValues.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { compareKunaAddresses } from "./KunaAddress.js";
|
||||
import { parseKunaJson } from "./KunaJson.js";
|
||||
|
||||
import {
|
||||
KUNA_STRING_OUTPUT_BYTES,
|
||||
KUNA_STRING_MIN_LENGTH,
|
||||
} from "./KunaLimits.js";
|
||||
|
||||
const count = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
||||
const address = z.string().regex(/^0x(?:0|[1-9a-f][0-9a-f]{0,15})$/u);
|
||||
const addressKeys = new Set(["address"]);
|
||||
const header = z.object({
|
||||
binary: z.string(),
|
||||
encoding: z.literal("all"),
|
||||
min_length: z.literal(KUNA_STRING_MIN_LENGTH),
|
||||
filter: z.null(),
|
||||
section: z.null(),
|
||||
termination: z.literal("any"),
|
||||
scanned: z.enum(["sections", "segments"]),
|
||||
xrefs: z.literal(false),
|
||||
count,
|
||||
strings: z.array(z.unknown()),
|
||||
});
|
||||
const row = z.object({
|
||||
address,
|
||||
address_hex: address,
|
||||
text: z.string(),
|
||||
length: count,
|
||||
byte_length: count,
|
||||
nul_terminated: z.boolean(),
|
||||
encoding: z.enum(["ascii", "utf8", "utf16"]),
|
||||
section: z.string().nullable(),
|
||||
xrefs_count: z.literal(0),
|
||||
functions: z.tuple([]),
|
||||
});
|
||||
|
||||
/** JSON text omits terminators; native lengths describe the encoded source. */
|
||||
const validateStringFacts = (entry: z.infer<typeof row>): void => {
|
||||
let characters = 0;
|
||||
for (const character of entry.text) {
|
||||
const point = character.codePointAt(0)!;
|
||||
if (
|
||||
point === 0 ||
|
||||
(point >= 0xd800 && point <= 0xdfff) ||
|
||||
(entry.encoding === "ascii" && point > 0x7f) ||
|
||||
(entry.encoding === "utf16" && point > 0xff)
|
||||
)
|
||||
throw new Error("string text contradicts its reported encoding");
|
||||
characters += 1;
|
||||
}
|
||||
const textBytes =
|
||||
entry.encoding === "utf16"
|
||||
? entry.text.length * 2
|
||||
: entry.encoding === "ascii"
|
||||
? entry.text.length
|
||||
: Buffer.byteLength(entry.text);
|
||||
const terminatorBytes = entry.nul_terminated
|
||||
? entry.encoding === "utf16"
|
||||
? 2
|
||||
: 1
|
||||
: 0;
|
||||
if (
|
||||
characters < KUNA_STRING_MIN_LENGTH ||
|
||||
entry.length !== characters ||
|
||||
entry.byte_length !== textBytes + terminatorBytes
|
||||
)
|
||||
throw new Error(
|
||||
"string length or termination contradicts its encoded text",
|
||||
);
|
||||
};
|
||||
|
||||
export type KunaStringItem = z.infer<typeof analysisStringSchema>;
|
||||
|
||||
export interface KunaStringInventory {
|
||||
readonly items: readonly KunaStringItem[];
|
||||
readonly sha256: string;
|
||||
readonly bytes: number;
|
||||
readonly scanned: "sections" | "segments";
|
||||
}
|
||||
|
||||
/** Validate the producer's complete scan before publishing any selected row. */
|
||||
export const parseKunaStringInventory = (
|
||||
text: string,
|
||||
input: string,
|
||||
): Result<KunaStringInventory, string> => {
|
||||
try {
|
||||
const document = header.parse(
|
||||
parseKunaJson(text, KUNA_STRING_OUTPUT_BYTES, addressKeys),
|
||||
);
|
||||
if (document.binary !== input)
|
||||
throw new Error("string inventory names a different input");
|
||||
if (document.count !== document.strings.length)
|
||||
throw new Error("string inventory count disagrees with examined rows");
|
||||
const seen = new Set<string>();
|
||||
const items = document.strings.map((value): KunaStringItem => {
|
||||
const entry = row.parse(value);
|
||||
validateStringFacts(entry);
|
||||
if (entry.address !== entry.address_hex)
|
||||
throw new Error("string address disagrees with its hex field");
|
||||
const identity = `${entry.address}:${entry.encoding}`;
|
||||
if (seen.has(identity))
|
||||
throw new Error("string inventory repeats an identity");
|
||||
seen.add(identity);
|
||||
return {
|
||||
address: entry.address,
|
||||
value: entry.text,
|
||||
string: {
|
||||
encoding: entry.encoding,
|
||||
encoding_status: "observed",
|
||||
termination: entry.nul_terminated
|
||||
? "present_or_not_required"
|
||||
: "missing",
|
||||
byte_length: entry.byte_length,
|
||||
},
|
||||
};
|
||||
});
|
||||
items.sort(
|
||||
(left, right) =>
|
||||
compareKunaAddresses(left.address, right.address) ||
|
||||
(left.string?.encoding ?? "").localeCompare(
|
||||
right.string?.encoding ?? "",
|
||||
"en",
|
||||
),
|
||||
);
|
||||
return ok({
|
||||
items,
|
||||
sha256: createHash("sha256").update(text).digest("hex"),
|
||||
bytes: Buffer.byteLength(text),
|
||||
scanned: document.scanned,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
return err(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
analysisSearchPredicate,
|
||||
analysisSearchQuerySchema,
|
||||
} from "../domain/analysisSearch.js";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { normalizeKunaAddress } from "./KunaAddress.js";
|
||||
import type { KunaInstallation } from "./KunaInstallation.js";
|
||||
import type { KunaPendingQuery, KunaQueryWork } from "./KunaQueryWork.js";
|
||||
import { kunaQueryFailureForCaller } from "./KunaQueryErrors.js";
|
||||
import {
|
||||
KUNA_STRING_MIN_LENGTH,
|
||||
KUNA_STRING_OUTPUT_BYTES,
|
||||
} from "./KunaLimits.js";
|
||||
import {
|
||||
parseKunaStringInventory,
|
||||
type KunaStringInventory,
|
||||
type KunaStringItem,
|
||||
} from "./KunaStringInventory.js";
|
||||
|
||||
/** One validated scan shared by literal, regex and exact-address queries. */
|
||||
export class KunaStringQueries {
|
||||
#inventory: KunaStringInventory | undefined;
|
||||
#failure: AnalysisError | undefined;
|
||||
#pending: KunaPendingQuery<KunaStringInventory> | undefined;
|
||||
|
||||
constructor(private readonly work: KunaQueryWork) {}
|
||||
|
||||
async execute(
|
||||
operation: "list_strings" | "search_strings",
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
installation: KunaInstallation,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<
|
||||
Result<
|
||||
{ readonly result: JsonValue; readonly source: JsonValue },
|
||||
AnalysisError
|
||||
>
|
||||
> {
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
const query =
|
||||
operation === "search_strings"
|
||||
? analysisSearchQuerySchema.safeParse(parameters)
|
||||
: undefined;
|
||||
if (query !== undefined && !query.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(operation, query.error.issues, parameters),
|
||||
);
|
||||
const address =
|
||||
operation === "list_strings" && parameters.address !== undefined
|
||||
? normalizeKunaAddress(parameters.address)
|
||||
: undefined;
|
||||
if (
|
||||
operation === "list_strings" &&
|
||||
parameters.address !== undefined &&
|
||||
address === undefined
|
||||
)
|
||||
return err(
|
||||
new AnalysisInputError(operation, {
|
||||
userMessage: "Use an explicit hexadecimal u64 string address.",
|
||||
}),
|
||||
);
|
||||
const inventory = await this.#load(installation, operation, signal);
|
||||
if (!inventory.ok)
|
||||
return err(kunaQueryFailureForCaller(inventory.error, operation));
|
||||
if (this.#cancelled(signal))
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
const available = inventory.value.items;
|
||||
let items: KunaStringItem[] | undefined;
|
||||
if (address !== undefined)
|
||||
items = available.filter((item) => item.address === address);
|
||||
if (query?.success === true) {
|
||||
if (query.data.mode === "regex") {
|
||||
const matched = await this.work.match({
|
||||
operation,
|
||||
pattern: query.data.pattern,
|
||||
caseSensitive: query.data.case_sensitive,
|
||||
values: () => available.map((item) => item.value),
|
||||
signal,
|
||||
});
|
||||
if (!matched.ok) return matched;
|
||||
items = matched.value.map((index) => available[index]!);
|
||||
} else {
|
||||
const predicate = analysisSearchPredicate(query.data);
|
||||
items = available.filter((item) => predicate(item.value));
|
||||
}
|
||||
}
|
||||
const selected = items ?? [...available];
|
||||
return ok({
|
||||
result: selected,
|
||||
source: {
|
||||
kind: "kuna-string-inventory",
|
||||
sha256: inventory.value.sha256,
|
||||
bytes: inventory.value.bytes,
|
||||
scanned: inventory.value.scanned,
|
||||
encoding: "all",
|
||||
min_length: KUNA_STRING_MIN_LENGTH,
|
||||
termination: "any",
|
||||
xrefs: false,
|
||||
inventory_count: inventory.value.items.length,
|
||||
selected_count: selected.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async #load(
|
||||
installation: KunaInstallation,
|
||||
operation: string,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Result<KunaStringInventory, AnalysisError>> {
|
||||
if (this.#inventory !== undefined) return ok(this.#inventory);
|
||||
if (this.#failure !== undefined) return err(this.#failure);
|
||||
if (this.#pending?.controller.signal.aborted === true) {
|
||||
const stopped = await this.work.wait(this.#pending, signal, operation);
|
||||
if (!stopped.ok && !(stopped.error instanceof AnalysisCancelledError))
|
||||
return stopped;
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
return this.#load(installation, operation, signal);
|
||||
}
|
||||
if (this.#pending === undefined) {
|
||||
const pending = this.work.start<KunaStringInventory>(
|
||||
async (producerSignal) => {
|
||||
const run = await this.work.run({
|
||||
installation,
|
||||
operation: "kuna:strings",
|
||||
arguments: (input) => [
|
||||
"strings",
|
||||
input,
|
||||
"--json",
|
||||
"--encoding",
|
||||
"all",
|
||||
"--min-length",
|
||||
String(KUNA_STRING_MIN_LENGTH),
|
||||
"--termination",
|
||||
"any",
|
||||
"--no-xrefs",
|
||||
],
|
||||
timeoutMs: installation.configuration.projectTimeoutMs,
|
||||
outputBytes: KUNA_STRING_OUTPUT_BYTES,
|
||||
signal: producerSignal,
|
||||
});
|
||||
if (!run.ok) {
|
||||
if (
|
||||
run.error instanceof AnalysisResourceConstraintError &&
|
||||
run.error.resource === "memory"
|
||||
)
|
||||
this.#failure = run.error;
|
||||
return run;
|
||||
}
|
||||
const parsed = parseKunaStringInventory(
|
||||
run.value.stdout.text,
|
||||
run.value.input,
|
||||
);
|
||||
return parsed.ok
|
||||
? parsed
|
||||
: err(
|
||||
new AnalysisOutputError("kuna:strings", parsed.error, {
|
||||
userMessage: `Kuna's string inventory cannot be used: ${parsed.error}`,
|
||||
}),
|
||||
);
|
||||
},
|
||||
(value) => {
|
||||
this.#inventory = value;
|
||||
},
|
||||
);
|
||||
this.#pending = pending;
|
||||
const release = () => {
|
||||
if (this.#pending === pending) this.#pending = undefined;
|
||||
};
|
||||
void pending.promise.then(release, release);
|
||||
}
|
||||
return this.work.wait(this.#pending, signal, operation);
|
||||
}
|
||||
|
||||
#cancelled(signal: AbortSignal | undefined): boolean {
|
||||
return signal?.aborted === true;
|
||||
}
|
||||
|
||||
/** Called only after session-owned work has drained. */
|
||||
clear(): void {
|
||||
this.#inventory = undefined;
|
||||
this.#failure = undefined;
|
||||
this.#pending = undefined;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@ export interface FakeKunaFunction {
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
export interface FakeKunaString {
|
||||
readonly address: number;
|
||||
readonly text: string;
|
||||
readonly encoding?: "ascii" | "utf8" | "utf16";
|
||||
readonly terminated?: boolean;
|
||||
}
|
||||
|
||||
/** Scripted behavior of the fake `kuna` executable for one test. */
|
||||
export interface FakeKunaBehavior {
|
||||
/** Text printed by `--version`; `exit` fails the probe instead. */
|
||||
@@ -48,6 +55,10 @@ export interface FakeKunaBehavior {
|
||||
readonly xrefs?: "ok" | "hang" | "not-json";
|
||||
readonly referenceDelayMs?: number;
|
||||
readonly referencePadding?: number;
|
||||
readonly strings?: "ok" | "hang" | "not-json" | "miscounted" | "exit-failure";
|
||||
readonly stringRows?: readonly FakeKunaString[];
|
||||
readonly stringDelayMs?: number;
|
||||
readonly stringPadding?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,6 +88,16 @@ if (args[0] === "--version") {
|
||||
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 }));
|
||||
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") {
|
||||
if (behavior.strings === "exit-failure") { process.stderr.write("fixture string scan failed\n"); process.exit(7); }
|
||||
else if (behavior.strings === "hang") hang();
|
||||
else if (behavior.strings === "not-json") process.stdout.write("not string JSON");
|
||||
else {
|
||||
if (!args.includes("--no-xrefs") || args[args.indexOf("--encoding") + 1] !== "all") throw new Error("strings must skip reference analysis and include all supported encodings");
|
||||
const rows = (behavior.stringRows ?? [{ address: 12288, text: "REA fixture prompt" }, { address: 12320, text: "Δelta", encoding: "utf8", terminated: false }]).map((item) => ({ address: item.address, address_hex: "0x" + item.address.toString(16), text: item.text, length: [...item.text].length, byte_length: Buffer.byteLength(item.text, item.encoding === "utf16" ? "utf16le" : "utf8") + (item.terminated === false ? 0 : item.encoding === "utf16" ? 2 : 1), nul_terminated: item.terminated !== false, encoding: item.encoding ?? "ascii", section: ".rodata", xrefs_count: 0, functions: [] }));
|
||||
const document = { binary: args[1], encoding: "all", min_length: 5, filter: null, section: null, termination: "any", scanned: "sections", xrefs: false, count: rows.length + (behavior.strings === "miscounted" ? 1 : 0), strings: rows, padding: "x".repeat(behavior.stringPadding ?? 0) };
|
||||
setTimeout(() => process.stdout.write(JSON.stringify(document)), behavior.stringDelayMs ?? 0);
|
||||
}
|
||||
} else if (args[0] === "decompile-graph") {
|
||||
if (behavior.graph === "hang") hang();
|
||||
else if (behavior.graph === "not-json") process.stdout.write("not graph JSON");
|
||||
|
||||
@@ -533,9 +533,7 @@ it("refuses another document, an unsupported operation and a replaced executable
|
||||
document: "/other/program",
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
||||
expect(
|
||||
await client.execute("search_strings", { pattern: "unused" }),
|
||||
).toMatchObject({
|
||||
expect(await client.execute("list_segments", {})).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { KunaQueryWork } from "../../../../src/kuna/KunaQueryWork.js";
|
||||
import { KunaRunSlots } from "../../../../src/kuna/KunaRunSlots.js";
|
||||
import { KunaTargetCommands } from "../../../../src/kuna/KunaTargetCommands.js";
|
||||
|
||||
it("does not materialize values for a cancelled queued regex query", async () => {
|
||||
const lifetime = new AbortController();
|
||||
// This test exercises worker admission only; no native command reads this target.
|
||||
const commands = new KunaTargetCommands(
|
||||
{
|
||||
path: "/unused/program",
|
||||
sha256: "a".repeat(64),
|
||||
kind: "executable",
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
availableArchitectures: ["x86_64"],
|
||||
},
|
||||
{ signal: lifetime.signal },
|
||||
);
|
||||
const work = new KunaQueryWork(commands, {
|
||||
signal: lifetime.signal,
|
||||
own: (promise) => promise,
|
||||
runSlots: new KunaRunSlots(1),
|
||||
});
|
||||
let admitted!: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
admitted = resolve;
|
||||
});
|
||||
const slow = work.match({
|
||||
operation: "search_strings",
|
||||
pattern: "^(a+)+$",
|
||||
caseSensitive: true,
|
||||
values: () => {
|
||||
admitted();
|
||||
return [`${"a".repeat(40)}!`];
|
||||
},
|
||||
});
|
||||
try {
|
||||
await ready;
|
||||
const cancelled = new AbortController();
|
||||
let materialized = false;
|
||||
const queued = work.match({
|
||||
operation: "search_procedures",
|
||||
pattern: "value",
|
||||
caseSensitive: true,
|
||||
signal: cancelled.signal,
|
||||
values: () => {
|
||||
materialized = true;
|
||||
return ["value"];
|
||||
},
|
||||
});
|
||||
cancelled.abort();
|
||||
expect(await queued).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
expect(materialized).toBe(false);
|
||||
lifetime.abort();
|
||||
expect(await slow).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
} finally {
|
||||
lifetime.abort();
|
||||
await slow;
|
||||
expect(await commands.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
});
|
||||
@@ -8,12 +8,22 @@ import { session } from "./kunaSessionFixture.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
it.each(["cancel", "deadline"])(
|
||||
"keeps regex %s bounded and the same session usable",
|
||||
async (mode) => {
|
||||
it.each([
|
||||
{ mode: "cancel", operation: "search_procedures" },
|
||||
{ mode: "deadline", operation: "search_procedures" },
|
||||
{ mode: "cancel", operation: "search_strings" },
|
||||
{ mode: "deadline", operation: "search_strings" },
|
||||
{ mode: "close", operation: "search_strings" },
|
||||
])(
|
||||
"bounds $operation regex $mode and settles its worker",
|
||||
async ({ mode, operation }) => {
|
||||
const { kuna, target, cacheRoot, profile } = await session(
|
||||
"rea-kuna-regex-",
|
||||
{
|
||||
stringRows: [
|
||||
{ text: `${"a".repeat(40)}!`, address: 4096 },
|
||||
{ text: "Δelta", address: 4160, encoding: "utf8" },
|
||||
],
|
||||
functions: [
|
||||
{ name: `${"a".repeat(40)}!`, addr: "0x1000" },
|
||||
{ name: "Δelta", addr: "0x1040" },
|
||||
@@ -28,22 +38,29 @@ import { KunaProvider } from ${JSON.stringify(pathToFileURL(resolve("dist/kuna/K
|
||||
const provider = new KunaProvider(${JSON.stringify({ REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: cacheRoot })});
|
||||
const client = provider.createClient(${JSON.stringify(target)}, ${JSON.stringify(profile)});
|
||||
try {
|
||||
assert.equal((await client.execute("procedure_assembly", {procedure:"0x1000"})).ok, true);
|
||||
assert.equal((await client.execute(${JSON.stringify(operation === "search_strings" ? "list_strings" : "procedure_assembly")}, ${operation === "search_strings" ? "{}" : '{procedure:"0x1000"}'})).ok, true);
|
||||
const options = ${mode === "cancel" ? "{signal:AbortSignal.timeout(100)}" : "{}"};
|
||||
const matched = await client.execute("search_procedures", {pattern:"^(a+)+$",mode:"regex"}, options);
|
||||
let closing;
|
||||
if (${JSON.stringify(mode)} === "close") setTimeout(() => { closing = client.close(); }, 100);
|
||||
const matched = await client.execute(${JSON.stringify(operation)}, {pattern:"^(a+)+$",mode:"regex"}, options);
|
||||
assert.equal(matched.ok, false);
|
||||
assert.equal(matched.error._tag, ${JSON.stringify(mode === "cancel" ? "AnalysisCancelledError" : "AnalysisResourceConstraintError")});
|
||||
assert.equal(matched.error._tag, ${JSON.stringify(mode !== "deadline" ? "AnalysisCancelledError" : "AnalysisResourceConstraintError")});
|
||||
if (${JSON.stringify(mode)} === "deadline") assert.equal(matched.error.reportedLimits.matching_deadline_ms, 5000);
|
||||
if (${JSON.stringify(mode)} === "close") {
|
||||
assert.equal((await closing).ok, true);
|
||||
assert.equal((await client.execute("health", {})).ok, false);
|
||||
} else {
|
||||
assert.equal((await client.execute("health", {})).ok, true);
|
||||
const unicode = await client.execute("search_procedures", {pattern:"^δ",mode:"regex"});
|
||||
const unicode = await client.execute(${JSON.stringify(operation)}, {pattern:"^δ",mode:"regex"});
|
||||
assert.equal(unicode.ok, true);
|
||||
assert.deepEqual(unicode.value.result, [{address:"0x1040",value:"Δelta"}]);
|
||||
const invalid = await client.execute("search_procedures", {pattern:"[",mode:"regex"});
|
||||
assert.deepEqual(unicode.value.result.map(({address,value}) => ({address,value})), [{address:"0x1040",value:"Δelta"}]);
|
||||
const invalid = await client.execute(${JSON.stringify(operation)}, {pattern:"[",mode:"regex"});
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.equal(invalid.error._tag, "AnalysisInputError");
|
||||
const literal = await client.execute("search_procedures", {pattern:"^(a+)+$"});
|
||||
const literal = await client.execute(${JSON.stringify(operation)}, {pattern:"^(a+)+$"});
|
||||
assert.equal(literal.ok, true);
|
||||
assert.deepEqual(literal.value.result, []);
|
||||
}
|
||||
} finally {
|
||||
assert.equal((await client.close()).ok, true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { parseKunaStringInventory } from "../../../../src/kuna/KunaStringInventory.js";
|
||||
|
||||
const input = "/private/program";
|
||||
const row = {
|
||||
address: "@9007199254740993",
|
||||
address_hex: "0x20000000000001",
|
||||
text: "a\nΔelta",
|
||||
length: 7,
|
||||
byte_length: 9,
|
||||
nul_terminated: true,
|
||||
encoding: "utf8",
|
||||
section: ".rodata",
|
||||
xrefs_count: 0,
|
||||
functions: [],
|
||||
};
|
||||
const document = () => ({
|
||||
binary: input,
|
||||
encoding: "all",
|
||||
min_length: 5,
|
||||
filter: null,
|
||||
section: null,
|
||||
termination: "any",
|
||||
scanned: "sections",
|
||||
xrefs: false,
|
||||
count: 1,
|
||||
strings: [row],
|
||||
});
|
||||
const wire = (value: unknown) =>
|
||||
JSON.stringify(value).replace(/"@(\d+)"/gu, "$1");
|
||||
|
||||
it("preserves exact u64 string identity, multiline text and typed source facts", () => {
|
||||
const result = parseKunaStringInventory(wire(document()), input);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
sha256: expect.stringMatching(/^[0-9a-f]{64}$/u),
|
||||
scanned: "sections",
|
||||
items: [
|
||||
{
|
||||
address: "0x20000000000001",
|
||||
value: "a\nΔelta",
|
||||
string: {
|
||||
encoding: "utf8",
|
||||
encoding_status: "observed",
|
||||
termination: "present_or_not_required",
|
||||
byte_length: 9,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["input", { binary: "/other/program" }],
|
||||
["count", { count: 2 }],
|
||||
["filter", { filter: "narrowed" }],
|
||||
["xrefs", { xrefs: true }],
|
||||
["encoding", { encoding: "ascii" }],
|
||||
["scan scope", { scanned: "unknown" }],
|
||||
[
|
||||
"address identity",
|
||||
{ strings: [{ ...row, address_hex: "0x20000000000000" }] },
|
||||
],
|
||||
["u64 overflow", { strings: [{ ...row, address: "@18446744073709551616" }] }],
|
||||
["partial row", { strings: [{ ...row, byte_length: null }] }],
|
||||
["unexpected references", { strings: [{ ...row, xrefs_count: 1 }] }],
|
||||
["duplicate identity", { count: 2, strings: [row, row] }],
|
||||
])(
|
||||
"rejects inconsistent %s before publishing any strings",
|
||||
(_label, changes) => {
|
||||
expect(
|
||||
parseKunaStringInventory(wire({ ...document(), ...changes }), input).ok,
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ encoding: "utf8", text: "😀😀😀😀😀", length: 5, byte_length: 21 },
|
||||
{ encoding: "utf16", text: "café!", length: 5, byte_length: 12 },
|
||||
])("counts $encoding source units without losing text", (facts) => {
|
||||
const value = { ...document(), strings: [{ ...row, ...facts }] };
|
||||
expect(parseKunaStringInventory(wire(value), input)).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [
|
||||
{ value: facts.text, string: { byte_length: facts.byte_length } },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ byte_length: 0 },
|
||||
{ length: 6 },
|
||||
{ nul_terminated: false },
|
||||
{ encoding: "ascii" },
|
||||
{ encoding: "utf16", text: "hello", length: 5, byte_length: 11 },
|
||||
{ encoding: "utf8", text: "hi", length: 2, byte_length: 3 },
|
||||
{ encoding: "utf8", text: "hello\u0000", length: 6, byte_length: 7 },
|
||||
{ encoding: "utf8", text: "hello\ud800", length: 6, byte_length: 9 },
|
||||
{ encoding: "utf16", text: "Δelta", length: 5, byte_length: 12 },
|
||||
])("rejects conflicting encoded string facts: %j", (facts) => {
|
||||
const value = { ...document(), strings: [{ ...row, ...facts }] };
|
||||
expect(parseKunaStringInventory(wire(value), input).ok).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import { expect, it as test, vi } from "vitest";
|
||||
import { readKunaConfiguration } from "../../../../src/kuna/KunaConfiguration.js";
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js";
|
||||
|
||||
import { fakeKunaCalls, setFakeKunaBehavior } from "./fakeKuna.js";
|
||||
import { run, session, unprovenCleanupLauncher } from "./kunaSessionFixture.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
const scans = async (kuna: string) =>
|
||||
(await fakeKunaCalls(kuna)).filter(({ argv }) => argv[0] === "strings");
|
||||
|
||||
it("shares a typed string scan across concurrent searches and exact address reads without analysis", async () => {
|
||||
const { open, kuna } = await session("rea-kuna-strings-", {
|
||||
stringDelayMs: 50,
|
||||
});
|
||||
const client = open();
|
||||
try {
|
||||
const [literal, regex] = await Promise.all([
|
||||
run(client, "search_strings", { pattern: "FIXTURE" }),
|
||||
run(client, "search_strings", { pattern: "^δ", mode: "regex" }),
|
||||
]);
|
||||
expect(literal.result).toEqual([
|
||||
{
|
||||
address: "0x3000",
|
||||
value: "REA fixture prompt",
|
||||
string: {
|
||||
encoding: "ascii",
|
||||
encoding_status: "observed",
|
||||
termination: "present_or_not_required",
|
||||
byte_length: 19,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(regex.result).toEqual([
|
||||
{
|
||||
address: "0x3020",
|
||||
value: "Δelta",
|
||||
string: {
|
||||
encoding: "utf8",
|
||||
encoding_status: "observed",
|
||||
termination: "missing",
|
||||
byte_length: 6,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(literal.rawResult).toMatchObject({
|
||||
strings: {
|
||||
kind: "kuna-string-inventory",
|
||||
sha256: expect.stringMatching(/^[0-9a-f]{64}$/u),
|
||||
inventory_count: 2,
|
||||
selected_count: 1,
|
||||
scanned: "sections",
|
||||
xrefs: false,
|
||||
},
|
||||
});
|
||||
expect(literal.limitations).toContainEqual(
|
||||
expect.stringContaining("not proof of absence"),
|
||||
);
|
||||
const exact = await run(client, "list_strings", { address: "0X00003020" });
|
||||
expect(exact.result).toEqual(regex.result);
|
||||
expect(
|
||||
(await run(client, "list_strings", { address: "0x9999" })).result,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(
|
||||
await run(client, "search_strings", {
|
||||
pattern: "fixture",
|
||||
case_sensitive: true,
|
||||
})
|
||||
).result,
|
||||
).toEqual(literal.result);
|
||||
expect(
|
||||
(
|
||||
await run(client, "search_strings", {
|
||||
pattern: "FIXTURE",
|
||||
case_sensitive: true,
|
||||
})
|
||||
).result,
|
||||
).toEqual([]);
|
||||
expect(await scans(kuna)).toHaveLength(1);
|
||||
const commands = (await fakeKunaCalls(kuna)).map(({ argv }) => argv[0]);
|
||||
expect(commands.filter((command) => command !== "--version")).toEqual([
|
||||
"strings",
|
||||
]);
|
||||
expect(
|
||||
await client.execute("list_strings", { address: "invalid" }),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
||||
expect(
|
||||
await client.execute("search_strings", { pattern: "[", mode: "regex" }),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
||||
expect((await run(client, "list_strings")).result).toHaveLength(2);
|
||||
} finally {
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["not-json", "miscounted"] as const)(
|
||||
"does not publish or cache a %s inventory",
|
||||
async (strings) => {
|
||||
const { open, kuna } = await session("rea-kuna-strings-invalid-", {
|
||||
strings,
|
||||
});
|
||||
const client = open();
|
||||
try {
|
||||
expect(
|
||||
await client.execute("search_strings", { pattern: "fixture" }),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
|
||||
await setFakeKunaBehavior(kuna, {});
|
||||
expect((await run(client, "list_strings")).result).toHaveLength(2);
|
||||
expect(await scans(kuna)).toHaveLength(2);
|
||||
} finally {
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("cancels one waiter without stopping the shared scan, and rebuilds in a fresh session", async () => {
|
||||
const { open, kuna } = await session("rea-kuna-strings-waiters-", {
|
||||
stringDelayMs: 200,
|
||||
});
|
||||
const first = open();
|
||||
try {
|
||||
const abort = new AbortController();
|
||||
const cancelled = first.execute(
|
||||
"list_strings",
|
||||
{},
|
||||
{ signal: abort.signal },
|
||||
);
|
||||
const survivor = first.execute("search_strings", { pattern: "fixture" });
|
||||
await vi.waitFor(async () => expect(await scans(kuna)).toHaveLength(1));
|
||||
abort.abort();
|
||||
expect(await cancelled).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
expect(await survivor).toMatchObject({ ok: true });
|
||||
expect(await scans(kuna)).toHaveLength(1);
|
||||
} finally {
|
||||
expect(await first.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
const fresh = open();
|
||||
try {
|
||||
await run(fresh, "list_strings");
|
||||
expect(await scans(kuna)).toHaveLength(2);
|
||||
} finally {
|
||||
expect(await fresh.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("drains a cancelled last scan before retrying", async () => {
|
||||
const { open, kuna } = await session("rea-kuna-strings-retry-", {
|
||||
strings: "hang",
|
||||
});
|
||||
const client = open();
|
||||
try {
|
||||
const abort = new AbortController();
|
||||
const pending = client.execute(
|
||||
"list_strings",
|
||||
{},
|
||||
{ signal: abort.signal },
|
||||
);
|
||||
await vi.waitFor(async () => expect(await scans(kuna)).toHaveLength(1));
|
||||
abort.abort();
|
||||
expect(await pending).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
await setFakeKunaBehavior(kuna, {});
|
||||
expect((await run(client, "list_strings")).result).toHaveLength(2);
|
||||
} finally {
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("retains metadata admission until a string process stop is proven", async () => {
|
||||
const cleanup = unprovenCleanupLauncher("strings");
|
||||
const { open, kuna } = await session(
|
||||
"rea-kuna-strings-owner-",
|
||||
{ strings: "hang" },
|
||||
{},
|
||||
cleanup.launcher,
|
||||
1,
|
||||
);
|
||||
const client = open();
|
||||
const abort = new AbortController();
|
||||
try {
|
||||
const pending = client.execute(
|
||||
"list_strings",
|
||||
{},
|
||||
{ signal: abort.signal },
|
||||
);
|
||||
await vi.waitFor(async () => expect(await scans(kuna)).toHaveLength(1));
|
||||
abort.abort();
|
||||
expect(await pending).toMatchObject({
|
||||
ok: false,
|
||||
error: expect.any(ProviderCleanupError),
|
||||
});
|
||||
const queuedAbort = new AbortController();
|
||||
const queued = client.execute(
|
||||
"procedure_callees",
|
||||
{ procedure: "main" },
|
||||
{ signal: queuedAbort.signal },
|
||||
);
|
||||
queuedAbort.abort();
|
||||
expect(await queued).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
expect(
|
||||
(await fakeKunaCalls(kuna)).some(
|
||||
({ argv }) => argv[0] === "decompile-graph",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(await client.close()).toMatchObject({
|
||||
ok: false,
|
||||
error: expect.any(ProviderCleanupError),
|
||||
});
|
||||
} finally {
|
||||
cleanup.release();
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the whole-program deadline independently of the single-function budget", async () => {
|
||||
const { open, kuna } = await session(
|
||||
"rea-kuna-strings-deadline-",
|
||||
{ stringDelayMs: 200 },
|
||||
{
|
||||
REA_KUNA_TIMEOUT_MS: "20",
|
||||
REA_KUNA_PROJECT_TIMEOUT_MS: "1000",
|
||||
},
|
||||
);
|
||||
expect(
|
||||
readKunaConfiguration(
|
||||
{
|
||||
REA_KUNA_COMMAND: kuna,
|
||||
REA_KUNA_TIMEOUT_MS: "20",
|
||||
REA_KUNA_PROJECT_TIMEOUT_MS: "1000",
|
||||
},
|
||||
process.platform,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { functionTimeoutMs: 20, projectTimeoutMs: 1000 },
|
||||
});
|
||||
const client = open();
|
||||
try {
|
||||
expect((await run(client, "list_strings")).result).toHaveLength(2);
|
||||
} finally {
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ starter: "list_strings", mode: "not-json", tag: "AnalysisOutputError" },
|
||||
{ starter: "search_strings", mode: "not-json", tag: "AnalysisOutputError" },
|
||||
{
|
||||
starter: "list_strings",
|
||||
mode: "exit-failure",
|
||||
tag: "ProviderAdapterError",
|
||||
},
|
||||
{
|
||||
starter: "search_strings",
|
||||
mode: "exit-failure",
|
||||
tag: "ProviderAdapterError",
|
||||
},
|
||||
{ starter: "list_strings", mode: "hang", tag: "AnalysisTimeoutError" },
|
||||
{ starter: "search_strings", mode: "hang", tag: "AnalysisTimeoutError" },
|
||||
{
|
||||
starter: "list_strings",
|
||||
mode: "oversize",
|
||||
tag: "AnalysisResourceConstraintError",
|
||||
},
|
||||
{
|
||||
starter: "search_strings",
|
||||
mode: "oversize",
|
||||
tag: "AnalysisResourceConstraintError",
|
||||
},
|
||||
] as const)(
|
||||
"keeps per-waiter context for $mode failures started by $starter",
|
||||
async ({ starter, mode, tag }) => {
|
||||
const { open, kuna } = await session(
|
||||
"rea-kuna-shared-string-failure-",
|
||||
mode === "oversize"
|
||||
? { stringPadding: 16 * 1024 * 1024 }
|
||||
: { strings: mode },
|
||||
{ REA_KUNA_PROJECT_TIMEOUT_MS: mode === "hang" ? "500" : "5000" },
|
||||
);
|
||||
const client = open();
|
||||
try {
|
||||
const other =
|
||||
starter === "list_strings" ? "search_strings" : "list_strings";
|
||||
const operations = [starter, other] as const;
|
||||
const results = await Promise.all(
|
||||
operations.map((operation) =>
|
||||
client.execute(
|
||||
operation,
|
||||
operation === "search_strings" ? { pattern: "fixture" } : {},
|
||||
),
|
||||
),
|
||||
);
|
||||
for (const [index, result] of results.entries()) {
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: tag,
|
||||
operation: operations[index],
|
||||
diagnostics: { producer_operation: "kuna:strings" },
|
||||
},
|
||||
});
|
||||
if (result.ok) throw new Error("expected scan failure");
|
||||
if (mode === "exit-failure")
|
||||
expect(result.error.capturedOutput).toMatchObject({
|
||||
exit_code: 7,
|
||||
stderr: expect.stringContaining("fixture string scan failed"),
|
||||
});
|
||||
if (mode === "hang")
|
||||
expect(result.error).toMatchObject({ timeoutMs: 500 });
|
||||
if (mode === "oversize") {
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
retryable: false,
|
||||
});
|
||||
expect(result.error.capturedOutput).toMatchObject({
|
||||
stdout: "",
|
||||
truncated: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (results[0]?.ok === false && results[1]?.ok === false) {
|
||||
expect(results[0].error).not.toBe(results[1].error);
|
||||
expect(results[0].error.cause).toBe(results[1].error.cause);
|
||||
}
|
||||
expect(await scans(kuna)).toHaveLength(1);
|
||||
if (mode === "oversize") {
|
||||
await setFakeKunaBehavior(kuna, {});
|
||||
expect(
|
||||
await client.execute("search_strings", { pattern: "fixture" }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { operation: "search_strings", _tag: tag },
|
||||
});
|
||||
expect(await scans(kuna)).toHaveLength(1);
|
||||
}
|
||||
} finally {
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
if (mode === "oversize") {
|
||||
const fresh = open();
|
||||
try {
|
||||
expect((await run(fresh, "list_strings")).result).toHaveLength(2);
|
||||
} finally {
|
||||
expect(await fresh.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { expect, it as test } from "vitest";
|
||||
|
||||
@@ -9,6 +10,43 @@ import { fakeKunaCalls, writeFakeKuna } from "./fakeKuna.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
it("accepts empty caller inventories but requires source-owned fixture literals", async () => {
|
||||
const root = await createTestTempDirectory("rea-kuna-verifier-empty-");
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`
|
||||
import assert from "node:assert/strict";
|
||||
import { verifyKunaStrings } from ${JSON.stringify(pathToFileURL(resolve("scripts/lib/kuna-string-verifier.mjs")).href)};
|
||||
const calls = [];
|
||||
const options = {
|
||||
cacheRoot: ${JSON.stringify(root)}, nativeStrings: async () => [],
|
||||
call: async (name, input) => {
|
||||
calls.push({ name, input });
|
||||
return { value: [], ms: 0, bytes: 0, structured: {
|
||||
provider: { id: "kuna" }, evidence_id: "ev_empty",
|
||||
raw_result: { strings: { inventory_count: 0, selected_count: 0, xrefs: false, sha256: "a".repeat(64), bytes: 1 } },
|
||||
} };
|
||||
},
|
||||
};
|
||||
const checked = await verifyKunaStrings(options);
|
||||
assert.equal(checked.inventory_count, 0);
|
||||
assert.equal(checked.exact_address_ms, null);
|
||||
assert.ok(calls.some(call => call.name === "list_strings"));
|
||||
assert.ok(calls.some(call => call.name === "search_strings"));
|
||||
assert.ok(calls.every(call => call.input?.address === undefined));
|
||||
await assert.rejects(() => verifyKunaStrings({ ...options, expectedStrings: ["required fixture literal"] }), /Source-owned string fixture/);
|
||||
`,
|
||||
],
|
||||
{ encoding: "utf8", timeout: 10_000 },
|
||||
);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ populated: false, names: "main", reason: "No caller-built targets" },
|
||||
{ populated: true, names: "typo", reason: "No caller-built targets" },
|
||||
@@ -73,15 +111,50 @@ it.each([
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ rejectClose: false, rejectRead: true, wrongXrefs: false },
|
||||
{ rejectClose: true, rejectRead: false, wrongXrefs: false },
|
||||
{ rejectClose: true, rejectRead: true, wrongXrefs: false },
|
||||
{ rejectClose: false, rejectRead: false, wrongXrefs: true },
|
||||
{
|
||||
rejectClose: false,
|
||||
rejectRead: true,
|
||||
wrongXrefs: false,
|
||||
wrongStrings: false,
|
||||
},
|
||||
{
|
||||
rejectClose: true,
|
||||
rejectRead: false,
|
||||
wrongXrefs: false,
|
||||
wrongStrings: false,
|
||||
},
|
||||
{
|
||||
rejectClose: true,
|
||||
rejectRead: true,
|
||||
wrongXrefs: false,
|
||||
wrongStrings: false,
|
||||
},
|
||||
{
|
||||
rejectClose: false,
|
||||
rejectRead: false,
|
||||
wrongXrefs: true,
|
||||
wrongStrings: false,
|
||||
},
|
||||
{
|
||||
rejectClose: false,
|
||||
rejectRead: false,
|
||||
wrongXrefs: false,
|
||||
wrongStrings: true,
|
||||
},
|
||||
])(
|
||||
"reports failure-path lineage (cleanup: $rejectClose, read: $rejectRead, wrong xrefs: $wrongXrefs)",
|
||||
async ({ rejectClose, rejectRead, wrongXrefs }) => {
|
||||
"reports failure-path lineage (cleanup: $rejectClose, read: $rejectRead, wrong xrefs: $wrongXrefs, wrong strings: $wrongStrings)",
|
||||
async ({ rejectClose, rejectRead, wrongXrefs, wrongStrings }) => {
|
||||
const root = await createTestTempDirectory("rea-kuna-verifier-cleanup-");
|
||||
const kuna = await writeFakeKuna(root, {
|
||||
stringRows: [
|
||||
{ address: 12288, text: "REA fixture prompt" },
|
||||
{
|
||||
address: 12320,
|
||||
text: "_φ( °-°)/ REA unicode marker",
|
||||
encoding: "utf8",
|
||||
},
|
||||
{ address: 12400, text: "REA wide marker", encoding: "utf16" },
|
||||
],
|
||||
functions: [
|
||||
{ name: "main", addr: "0x1000" },
|
||||
{ name: "rea_checksum", addr: "0x1040" },
|
||||
@@ -111,11 +184,14 @@ const methods = new Map();
|
||||
const output = createInterface({ input: backend.stdout });
|
||||
output.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (${wrongXrefs} && methods.get(message.id) === "xrefs" && message.result?.structuredContent) {
|
||||
message.result.structuredContent.normalized_result = ["0xdeadbeef"];
|
||||
if ((${wrongXrefs} && methods.get(message.id) === "xrefs" || ${wrongStrings} && methods.get(message.id) === "search_strings") && message.result?.structuredContent) {
|
||||
const corrupted = ${wrongStrings}
|
||||
? message.result.structuredContent.normalized_result.map(row => ({ ...row, value: row.value + " [wrong string]" }))
|
||||
: ["0xdeadbeef"];
|
||||
message.result.structuredContent.normalized_result = corrupted;
|
||||
for (const block of message.result.content) if (block.type === "text") {
|
||||
const projection = JSON.parse(block.text);
|
||||
projection.normalized_result = ["0xdeadbeef"];
|
||||
projection.normalized_result = corrupted;
|
||||
block.text = JSON.stringify(projection);
|
||||
}
|
||||
}
|
||||
@@ -178,7 +254,9 @@ await exited;
|
||||
? "injected cleanup rejection"
|
||||
: wrongXrefs
|
||||
? "incoming references differ from exact-target native sites"
|
||||
: "injected verification failure",
|
||||
: wrongStrings
|
||||
? "String search differs from native scan"
|
||||
: "injected verification failure",
|
||||
);
|
||||
if (rejectRead)
|
||||
expect(JSON.stringify(report.failures)).toContain(
|
||||
|
||||
Reference in new issue
Block a user