mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42bfd8bf79 | ||
|
|
ac30fbd658 | ||
|
|
5d21bddb18 | ||
|
|
9926cbb740 | ||
|
|
4db1433264 | ||
|
|
2e2f981ccf | ||
|
|
f8bb792f15 | ||
|
|
1d00dbd6f2 | ||
|
|
91d9e6ebe3 | ||
|
|
45233309aa | ||
|
|
c02f7d1330 | ||
|
|
f8e56e2ab6 | ||
|
|
a240e19c2e | ||
|
|
db4b13cc53 | ||
|
|
98120f3158 |
No files matched your search
@@ -1414,8 +1414,29 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
flow.addProperty("conditional", false);
|
||||
flow.addProperty("computed", false);
|
||||
if (instruction == null) {
|
||||
result.addProperty("status", !currentProgram.getMemory().contains(address) ? "outside-memory" : currentProgram.getListing().getInstructionContaining(address) != null
|
||||
? "not-instruction-boundary" : currentProgram.getListing().getDefinedDataContaining(address) != null ? "data" : "not-in-listing");
|
||||
Instruction containing = currentProgram.getListing().getInstructionContaining(address);
|
||||
boolean inMemory = currentProgram.getMemory().contains(address);
|
||||
Data containingData = inMemory && containing == null
|
||||
? currentProgram.getListing().getDefinedDataContaining(address) : null;
|
||||
result.addProperty("status", !inMemory ? "outside-memory" : containing != null
|
||||
? "not-instruction-boundary" : containingData != null ? "data" : "not-in-listing");
|
||||
if (inMemory && containing == null && containingData != null) {
|
||||
JsonObject parentData = new JsonObject();
|
||||
parentData.addProperty("address", canonicalAddress(containingData.getAddress()));
|
||||
parentData.addProperty("length", containingData.getLength());
|
||||
parentData.addProperty("type_id", containingData.getDataType().getPathName());
|
||||
parentData.addProperty("provenance", "ghidra-listing");
|
||||
result.add("containing_data", parentData);
|
||||
}
|
||||
if (containing != null && currentProgram.getMemory().contains(address)) {
|
||||
JsonObject containingInstruction = new JsonObject();
|
||||
containingInstruction.addProperty("address", canonicalAddress(containing.getAddress()));
|
||||
containingInstruction.addProperty("length", containing.getLength());
|
||||
containingInstruction.addProperty("mnemonic", containing.getMnemonicString());
|
||||
containingInstruction.addProperty("raw_disassembly", containing.toString());
|
||||
containingInstruction.addProperty("provenance", "ghidra-listing");
|
||||
result.add("containing_instruction", containingInstruction);
|
||||
}
|
||||
for (String key : List.of("bytes", "length", "mnemonic", "raw_disassembly")) result.add(key, JsonNull.INSTANCE);
|
||||
} else {
|
||||
result.addProperty("status", "decoded");
|
||||
|
||||
@@ -310,8 +310,8 @@ entry it does not read. The module reads the central directory itself, as REA
|
||||
does, and shows `zipfile` only the records it lists, reading each entry in
|
||||
bounded chunks no further than a byte past its declared size. A JSON file
|
||||
holding a number JavaScript cannot read exactly, such as an integer above 2^53,
|
||||
or nested more than 500 levels deep, gets an `error` instead of a view, as in
|
||||
REA, while text that is not one JSON
|
||||
or whose retained value nests more than 500 levels deep, gets an `error` instead of a view, as in
|
||||
REA. Duplicate keys keep their last value before the depth check. Text that is not one JSON
|
||||
document, such as JSON Lines, stays text. Format detection follows REA's
|
||||
rules. Files larger than 64 MiB get an `error` instead of a view
|
||||
and are not read. XML is read with Python's `expat` twice: with namespace
|
||||
|
||||
@@ -267,6 +267,18 @@ same function can be retried after raising the limit. See the
|
||||
[settings design](design/limits-and-recovery.md) for snapshots, precedence,
|
||||
and provider integration.
|
||||
|
||||
When `inspect_native_instruction` selects a byte inside a listed instruction,
|
||||
`containing_instruction` reports its separate start, length and Ghidra Listing
|
||||
text, retaining any non-default address space. The requested address remains
|
||||
unchanged, and a containing function may be absent. For defined data, the same
|
||||
tool reports its containing object's start, length and type path; use the start
|
||||
with `inspect_native_data_type` to inspect its layout. This does not select a
|
||||
field or union member. These are listing observations, not proof of execution or
|
||||
an alternate decode. Earlier Evidence can
|
||||
omit the field. The analysis-profile revision prevents older snapshots from
|
||||
replaying results without it; open the target without the old snapshot and save
|
||||
a fresh snapshot when needed.
|
||||
|
||||
Function extents and instruction completeness refer to Ghidra's analyzed body.
|
||||
An inferred no-return call can terminate that body before subsequent bytes.
|
||||
Pseudocode, assembly, procedure info, instruction windows, incoming and outgoing
|
||||
|
||||
@@ -266,12 +266,10 @@ export const registerBinaryDiagnosticsCommands = (
|
||||
// "0x10" or "1e2".
|
||||
blockSize: cliDecimalField(
|
||||
inspectByteEntropyInputSchema.shape.block_size,
|
||||
).describe(
|
||||
"Power-of-two block size from 256 to 65536 bytes (default 4096)",
|
||||
),
|
||||
).describe("Power-of-two block size from 256 to 65536 bytes"),
|
||||
offset: cliDecimalField(
|
||||
inspectByteEntropyInputSchema.shape.offset,
|
||||
).describe("Window start as a decimal file byte offset (default 0)"),
|
||||
).describe("Window start as a decimal file byte offset"),
|
||||
length: cliDecimalField(
|
||||
inspectByteEntropyInputSchema.shape.length,
|
||||
).describe("Window length in bytes; omit to extend through EOF"),
|
||||
@@ -429,16 +427,16 @@ export const registerBinaryDiagnosticsCommands = (
|
||||
options: z.object({
|
||||
maxFileBytes: cliDecimalField(
|
||||
inspectDevicetreeInputSchema.shape.max_file_bytes,
|
||||
).describe("Maximum FDT file size in bytes (default 4194304)"),
|
||||
).describe("Maximum FDT file size in bytes"),
|
||||
maxNodes: cliDecimalField(
|
||||
inspectDevicetreeInputSchema.shape.max_nodes,
|
||||
).describe("Maximum number of device-tree nodes (default 8192)"),
|
||||
).describe("Maximum number of device-tree nodes"),
|
||||
maxProperties: cliDecimalField(
|
||||
inspectDevicetreeInputSchema.shape.max_properties,
|
||||
).describe("Maximum number of device-tree properties (default 32768)"),
|
||||
).describe("Maximum number of device-tree properties"),
|
||||
maxDepth: cliDecimalField(
|
||||
inspectDevicetreeInputSchema.shape.max_depth,
|
||||
).describe("Maximum device-tree nesting depth (default 64)"),
|
||||
).describe("Maximum device-tree nesting depth"),
|
||||
...analysisEvidenceDeliveryOptions,
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
|
||||
@@ -312,7 +312,7 @@ export const registerCoreNativeCommands = (
|
||||
cli.command(command, {
|
||||
description:
|
||||
operation === "inspect_native_instruction"
|
||||
? "Inspect one native instruction with decoded operand facts"
|
||||
? "Inspect a code or data address, its containing Ghidra listing object, and typed incoming references where supported"
|
||||
: "Resolve one static native call site",
|
||||
args: z.object({
|
||||
path: localPathStringSchema.describe(
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("CLI JSON path normalization", () => {
|
||||
expect.objectContaining({
|
||||
path: ["executable_path"],
|
||||
code: "invalid_format",
|
||||
message: expect.stringContaining("absolute local filesystem path"),
|
||||
format: "regex",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
),
|
||||
official(
|
||||
"inspect_native_instruction",
|
||||
"Inspect one exact code or data address: decoded bytes, mnemonic, ordered operand tokens, flow, outgoing instruction references and typed incoming references where available. Incoming references preserve source, access kind, operand index and analysis provenance even at data addresses; they do not prove runtime access. Page retained references with inspect_analysis_view kind native-instruction-references; null coverage means the selected collection was unavailable or not recorded. Memory addressing decomposition is unavailable when the provider supplies only tokens. Ghidra reports in-memory addresses without a listed instruction or defined data as not-in-listing, without attempting a decode.",
|
||||
"Inspect one exact code or data address: decoded bytes, mnemonic, ordered operand tokens, flow, outgoing instruction references and typed incoming references where available. For a byte inside a listed instruction or defined data, Ghidra reports the containing instruction or data object separately, preserving the query and address space. Inspect the starting address of a data object with inspect_native_data_type for its layout; a containing object does not select a field or union member. Incoming references preserve source, access kind, operand index and analysis provenance even at data addresses; they do not prove runtime access. Page retained references with inspect_analysis_view kind native-instruction-references; null coverage means the selected collection was unavailable or not recorded. Memory addressing decomposition is unavailable when the provider supplies only tokens. Ghidra reports in-memory addresses without a listed instruction or defined data as not-in-listing, without attempting a decode.",
|
||||
nativeInstructionInputSchema,
|
||||
),
|
||||
official(
|
||||
|
||||
@@ -51,7 +51,7 @@ const captureInputShape = z.strictObject({
|
||||
.int()
|
||||
.nonnegative()
|
||||
.default(0)
|
||||
.describe("Skip this many matching packets after filtering; default 0"),
|
||||
.describe("Skip this many matching packets after filtering"),
|
||||
match_limit: z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -5,9 +5,7 @@ export const inspectPacketFieldsInputSchema = z.strictObject({
|
||||
kind: z
|
||||
.enum(["protocols", "fields"])
|
||||
.default("protocols")
|
||||
.describe(
|
||||
"Return protocol registrations or field definitions; defaults to protocols",
|
||||
),
|
||||
.describe("Return protocol registrations or field definitions"),
|
||||
protocol: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -30,7 +28,7 @@ export const inspectPacketFieldsInputSchema = z.strictObject({
|
||||
.nonnegative()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.default(0)
|
||||
.describe("Number of matching records to skip; defaults to 0"),
|
||||
.describe("Number of matching records to skip"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PYTHON_JSON_READER } from "./appExportPythonJson.js";
|
||||
import { PYTHON_XML_READER } from "./appExportPythonXml.js";
|
||||
import { PYTHON_ZIP_HELPERS } from "./appExportPythonZip.js";
|
||||
|
||||
@@ -12,6 +13,7 @@ export const PYTHON_READY_CHECK = `
|
||||
|
||||
export const PYTHON_IMPORTS = `from __future__ import annotations
|
||||
|
||||
from array import array
|
||||
import contextlib
|
||||
import copy
|
||||
import hashlib
|
||||
@@ -57,11 +59,6 @@ MAX_PARSED_BYTES = 4 * 1024 * 1024
|
||||
PARSED_TOO_LARGE = "larger than the 4 MiB limit for JSON and key file views"
|
||||
MAX_JSON_VIEW_DEPTH = 500
|
||||
JSON_TOO_DEEP = f"nested deeper than the {MAX_JSON_VIEW_DEPTH} levels a JSON view keeps"
|
||||
#: A JSON token after any whitespace, by group: an opening, a closing, a comma, a colon, a string's quote, or a number
|
||||
#: or literal. Strings are read by json's scanner, since a pattern repeating per escape holds memory for each.
|
||||
JSON_TOKEN = re.compile(r'[ \t\n\r]*(?:([\[{])|([\]}])|(,)|(:)|(")|(-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?'
|
||||
r"|true|false|null))")
|
||||
JSON_SPACE = re.compile(r"[ \t\n\r]*")
|
||||
|
||||
|
||||
def _respelled(text: str, home: Path) -> str:
|
||||
@@ -254,143 +251,7 @@ def _keyfile(text: str) -> dict:
|
||||
|
||||
${PYTHON_XML_READER}
|
||||
|
||||
def _not_json(constant: str):
|
||||
raise ValueError(f"{constant} is not JSON")
|
||||
|
||||
|
||||
class _Inexact(Exception):
|
||||
"""A JSON number that REA's state view refuses, since reading it would change it."""
|
||||
|
||||
|
||||
def _digits(text: str) -> str:
|
||||
return re.split(r"[eE]", text.lstrip("-"))[0].replace(".", "").strip("0") or "0"
|
||||
|
||||
|
||||
def _js_string(value: float) -> str:
|
||||
"""How JavaScript's String() writes a finite double: an integer below 1e21 in full, any other in shortest form."""
|
||||
return str(int(value)) if value.is_integer() and abs(value) < 1e21 else repr(value)
|
||||
|
||||
|
||||
def _safe_number(text: str) -> bool:
|
||||
"""lossless-json's isSafeNumber: an integer spelling within 2^53, or a float spelling that keeps its digits."""
|
||||
if re.fullmatch(r"-?[0-9]+", text):
|
||||
return len(text.lstrip("-")) <= 16 and abs(int(text)) <= MAX_SAFE_INTEGER
|
||||
value = float(text)
|
||||
if not math.isfinite(value):
|
||||
return False
|
||||
written = _js_string(value)
|
||||
return written == text or _digits(written) == _digits(text)
|
||||
|
||||
|
||||
def _exact_number(text: str):
|
||||
"""A JSON number read only when reading it changes nothing, as REA's state view rules: its spelling and its
|
||||
written-back form both safe, and not -0."""
|
||||
value = float(text)
|
||||
if (not _safe_number(text) or not math.isfinite(value) or not _safe_number(_js_string(value))
|
||||
or (value == 0 and math.copysign(1, value) < 0)):
|
||||
raise _Inexact(text)
|
||||
return int(text) if re.fullmatch(r"-?[0-9]+", text) else value
|
||||
|
||||
|
||||
_INEXACT = object()
|
||||
_LEVEL_END = object()
|
||||
|
||||
|
||||
def _number_or_mark(spelling: str):
|
||||
"""A JSON number read exactly, or a mark for one REA's state view refuses."""
|
||||
try:
|
||||
return _exact_number(spelling)
|
||||
except _Inexact:
|
||||
return _INEXACT
|
||||
|
||||
|
||||
def _holds_inexact(value) -> bool:
|
||||
"""Whether a parsed JSON value still holds a marked number once duplicate keys kept their last value; one
|
||||
iterator per nesting level, so no child list is copied."""
|
||||
levels = [iter((value,))]
|
||||
while levels:
|
||||
item = next(levels[-1], _LEVEL_END)
|
||||
if item is _LEVEL_END:
|
||||
levels.pop()
|
||||
elif item is _INEXACT:
|
||||
return True
|
||||
elif isinstance(item, dict):
|
||||
levels.append(iter(item.values()))
|
||||
elif isinstance(item, list):
|
||||
levels.append(iter(item))
|
||||
return False
|
||||
|
||||
|
||||
def _deeper_than(root, limit: int) -> bool:
|
||||
"""Whether a parsed JSON value nests deeper than a limit, as REA checks it: a value's depth is the number of
|
||||
iterators open above it, one per nesting level, so no child list is copied."""
|
||||
levels = [iter((root,))]
|
||||
while levels:
|
||||
item = next(levels[-1], _LEVEL_END)
|
||||
if item is _LEVEL_END:
|
||||
levels.pop()
|
||||
elif len(levels) - 1 > limit:
|
||||
return True
|
||||
elif isinstance(item, dict):
|
||||
levels.append(iter(item.values()))
|
||||
elif isinstance(item, list):
|
||||
levels.append(iter(item))
|
||||
return False
|
||||
|
||||
|
||||
def _is_json(text: str) -> bool:
|
||||
"""Whether text is one JSON value as REA's state view reads it, checked a token at a time without building it or
|
||||
recursing: it holds a byte per open container and one string at a time, so text too deep for Python's parser or
|
||||
too large to parse into a view is still told apart from other text."""
|
||||
containers, expect, empty, at = bytearray(), "value", False, 0
|
||||
while True:
|
||||
token = JSON_TOKEN.match(text, at)
|
||||
if token is None:
|
||||
break
|
||||
kind, at = token.lastindex, token.end()
|
||||
if kind == 5:
|
||||
try:
|
||||
at = json.decoder.scanstring(text, at)[1]
|
||||
except ValueError:
|
||||
return False
|
||||
if kind == 1 and expect == "value":
|
||||
containers.append(ord(text[at - 1]))
|
||||
expect, empty = ("value" if text[at - 1] == "[" else "key"), True
|
||||
# A closing bracket's code is its opening's plus two.
|
||||
elif kind == 2 and (expect == "after" or empty) and containers and containers[-1] == ord(text[at - 1]) - 2:
|
||||
containers.pop()
|
||||
expect, empty = "after", False
|
||||
elif kind == 3 and expect == "after" and containers:
|
||||
expect = "value" if containers[-1] == ord("[") else "key"
|
||||
elif kind == 4 and expect == "colon":
|
||||
expect = "value"
|
||||
elif kind >= 5 and (expect == "value" or (kind == 5 and expect == "key")):
|
||||
expect, empty = ("colon" if expect == "key" else "after"), False
|
||||
else:
|
||||
return False
|
||||
return expect == "after" and not containers and JSON_SPACE.fullmatch(text, at) is not None
|
||||
|
||||
|
||||
def _json_view(text: str, size: int):
|
||||
"""A JSON text's format and view, or why it has none, as REA reads it; None when it is not JSON. Text over the
|
||||
parsed-view limit, or nested past Python's recursion limit, is told apart without building its value."""
|
||||
if size > MAX_PARSED_BYTES:
|
||||
return {"format": "json", "view": None, "error": PARSED_TOO_LARGE} if _is_json(text) else None
|
||||
# The whole text must parse, and duplicate keys settle, before an inexact number makes it JSON with an error.
|
||||
try:
|
||||
view = json.loads(text, parse_constant=_not_json, parse_float=_number_or_mark, parse_int=_number_or_mark)
|
||||
except RecursionError:
|
||||
# Nesting too deep for Python to parse is far deeper than REA's view keeps, once the rest of it is JSON too.
|
||||
return {"format": "json", "view": None, "error": JSON_TOO_DEEP} if _is_json(text) else None
|
||||
except ValueError:
|
||||
return None
|
||||
if _holds_inexact(view):
|
||||
return {"format": "json", "view": None,
|
||||
"error": "a number in it cannot be read exactly, such as an integer above 2^53"}
|
||||
if _deeper_than(view, MAX_JSON_VIEW_DEPTH):
|
||||
return {"format": "json", "view": None, "error": JSON_TOO_DEEP}
|
||||
return {"format": "json", "view": view, "truncated": False}
|
||||
|
||||
${PYTHON_JSON_READER}
|
||||
|
||||
def _text_view(name: str, text: str, size: int) -> dict:
|
||||
"""The format REA's state view detects for decoded text of size bytes, and its view; a JSON or key file over the
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/** JSON parsing and bounded retained-view checks for exported desktop environments. */
|
||||
export const PYTHON_JSON_READER = String.raw`
|
||||
#: A JSON token after any whitespace, by group: an opening, a closing, a comma, a colon, a string's quote, or a number
|
||||
#: or literal. Strings are read by json's scanner, since a pattern repeating per escape holds memory for each.
|
||||
JSON_TOKEN = re.compile(r'[ \t\n\r]*(?:([\[{])|([\]}])|(,)|(:)|(")|(-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?'
|
||||
r"|true|false|null))")
|
||||
JSON_SPACE = re.compile(r"[ \t\n\r]*")
|
||||
|
||||
|
||||
class _Inexact(Exception):
|
||||
"""A JSON number that REA's state view refuses, since reading it would change it."""
|
||||
|
||||
|
||||
def _digits(text: str) -> str:
|
||||
return re.split(r"[eE]", text.lstrip("-"))[0].replace(".", "").strip("0") or "0"
|
||||
|
||||
|
||||
def _js_string(value: float) -> str:
|
||||
"""How JavaScript's String() writes a finite double: an integer below 1e21 in full, any other in shortest form."""
|
||||
return str(int(value)) if value.is_integer() and abs(value) < 1e21 else repr(value)
|
||||
|
||||
|
||||
def _safe_number(text: str) -> bool:
|
||||
"""lossless-json's isSafeNumber: an integer spelling within 2^53, or a float spelling that keeps its digits."""
|
||||
if re.fullmatch(r"-?[0-9]+", text):
|
||||
return len(text.lstrip("-")) <= 16 and abs(int(text)) <= MAX_SAFE_INTEGER
|
||||
value = float(text)
|
||||
if not math.isfinite(value):
|
||||
return False
|
||||
written = _js_string(value)
|
||||
return written == text or _digits(written) == _digits(text)
|
||||
|
||||
|
||||
def _exact_number(text: str):
|
||||
"""A JSON number read only when reading it changes nothing, as REA's state view rules: its spelling and its
|
||||
written-back form both safe, and not -0."""
|
||||
value = float(text)
|
||||
if (not _safe_number(text) or not math.isfinite(value) or not _safe_number(_js_string(value))
|
||||
or (value == 0 and math.copysign(1, value) < 0)):
|
||||
raise _Inexact(text)
|
||||
return int(text) if re.fullmatch(r"-?[0-9]+", text) else value
|
||||
|
||||
|
||||
_INEXACT = object()
|
||||
_DEEP = object()
|
||||
_DEEP_INEXACT = object()
|
||||
_LEVEL_END = object()
|
||||
_INVALID_JSON = object()
|
||||
JSON_ATOMS = {"true": True, "false": False, "null": None}
|
||||
|
||||
|
||||
def _number_or_mark(spelling: str):
|
||||
"""A JSON number read exactly, or a mark for one REA's state view refuses."""
|
||||
try:
|
||||
return _exact_number(spelling)
|
||||
except _Inexact:
|
||||
return _INEXACT
|
||||
|
||||
|
||||
def _holds_inexact(value) -> bool:
|
||||
"""Whether a parsed JSON value still holds a marked number once duplicate keys kept their last value; one
|
||||
iterator per nesting level, so no child list is copied."""
|
||||
levels = [iter((value,))]
|
||||
while levels:
|
||||
item = next(levels[-1], _LEVEL_END)
|
||||
if item is _LEVEL_END:
|
||||
levels.pop()
|
||||
elif item is _INEXACT or item is _DEEP_INEXACT:
|
||||
return True
|
||||
elif isinstance(item, dict):
|
||||
levels.append(iter(item.values()))
|
||||
elif isinstance(item, list):
|
||||
levels.append(iter(item))
|
||||
return False
|
||||
|
||||
|
||||
def _deeper_than(root, limit: int) -> bool:
|
||||
"""Whether a parsed JSON value nests deeper than a limit, as REA checks it: a value's depth is the number of
|
||||
iterators open above it, one per nesting level, so no child list is copied."""
|
||||
levels = [iter((root,))]
|
||||
while levels:
|
||||
item = next(levels[-1], _LEVEL_END)
|
||||
if item is _LEVEL_END:
|
||||
levels.pop()
|
||||
elif item is _DEEP or item is _DEEP_INEXACT:
|
||||
return True
|
||||
elif len(levels) - 1 > limit:
|
||||
return True
|
||||
elif isinstance(item, dict):
|
||||
levels.append(iter(item.values()))
|
||||
elif isinstance(item, list):
|
||||
levels.append(iter(item))
|
||||
return False
|
||||
|
||||
|
||||
def _read_json(text: str, build: bool = True):
|
||||
"""One JSON value, or _INVALID_JSON, read without recursion so duplicate keys settle before depth is checked.
|
||||
Deep containers are summarized rather than built; exact-number marks still follow last-key-wins semantics.
|
||||
With build=False, only syntax is checked: one byte per open container and one decoded string at a time."""
|
||||
containers, expect, empty, at = bytearray(), "value", False, 0
|
||||
values, keys, root = [], [], _INVALID_JSON
|
||||
deep_bad, deep_key_at, bad_keys = bytearray(), array("I"), {}
|
||||
|
||||
def keep(value) -> None:
|
||||
nonlocal root
|
||||
if not values:
|
||||
root = value
|
||||
elif containers[-1] == ord("["):
|
||||
values[-1].append(value)
|
||||
else:
|
||||
values[-1][keys[-1]] = value
|
||||
|
||||
def record_deep(bad: bool) -> None:
|
||||
"""Summarize a finished child; keep only object keys with live inexact values."""
|
||||
depth = len(deep_bad) - 1
|
||||
if containers[-1] == ord("["):
|
||||
deep_bad[-1] |= 2 | bad
|
||||
return
|
||||
marked = bad_keys.get(depth)
|
||||
if bad or marked is not None:
|
||||
key, _ = json.decoder.scanstring(text, deep_key_at[-1])
|
||||
if bad:
|
||||
if marked is None:
|
||||
marked = key
|
||||
elif isinstance(marked, str):
|
||||
if marked != key:
|
||||
marked = {marked, key}
|
||||
else:
|
||||
marked.add(key)
|
||||
else:
|
||||
if isinstance(marked, str):
|
||||
if marked == key:
|
||||
marked = None
|
||||
else:
|
||||
marked.discard(key)
|
||||
if len(marked) == 1:
|
||||
marked = next(iter(marked))
|
||||
if marked is None:
|
||||
bad_keys.pop(depth, None)
|
||||
else:
|
||||
bad_keys[depth] = marked
|
||||
deep_bad[-1] = 2 | (marked is not None)
|
||||
else:
|
||||
deep_bad[-1] |= 2
|
||||
|
||||
while True:
|
||||
token = JSON_TOKEN.match(text, at)
|
||||
if token is None:
|
||||
break
|
||||
kind, at = token.lastindex, token.end()
|
||||
if kind == 5:
|
||||
string_at = at
|
||||
try:
|
||||
value, at = json.decoder.scanstring(text, at)
|
||||
except ValueError:
|
||||
return _INVALID_JSON
|
||||
if kind == 1 and expect == "value":
|
||||
if build:
|
||||
if deep_bad or len(containers) >= MAX_JSON_VIEW_DEPTH:
|
||||
deep_bad.append(0)
|
||||
deep_key_at.append(0)
|
||||
else:
|
||||
value = [] if text[at - 1] == "[" else {}
|
||||
keep(value)
|
||||
values.append(value)
|
||||
keys.append(None)
|
||||
containers.append(ord(text[at - 1]))
|
||||
expect, empty = ("value" if text[at - 1] == "[" else "key"), True
|
||||
# A closing bracket's code is its opening's plus two.
|
||||
elif kind == 2 and (expect == "after" or empty) and containers and containers[-1] == ord(text[at - 1]) - 2:
|
||||
opening = containers.pop()
|
||||
if build:
|
||||
if deep_bad:
|
||||
state = deep_bad.pop()
|
||||
bad = bool(state & 1)
|
||||
deep_key_at.pop()
|
||||
bad_keys.pop(len(deep_bad), None)
|
||||
if deep_bad:
|
||||
record_deep(bad)
|
||||
else:
|
||||
keep((_DEEP_INEXACT if bad else _DEEP) if state & 2 else ([] if opening == ord("[") else {}))
|
||||
else:
|
||||
values.pop()
|
||||
keys.pop()
|
||||
expect, empty = "after", False
|
||||
elif kind == 3 and expect == "after" and containers:
|
||||
expect = "value" if containers[-1] == ord("[") else "key"
|
||||
elif kind == 4 and expect == "colon":
|
||||
expect = "value"
|
||||
elif kind >= 5 and (expect == "value" or (kind == 5 and expect == "key")):
|
||||
if build:
|
||||
if expect == "key":
|
||||
if deep_bad:
|
||||
deep_key_at[-1] = string_at
|
||||
else:
|
||||
keys[-1] = value
|
||||
else:
|
||||
if kind == 6:
|
||||
value = JSON_ATOMS.get(token[6], _INVALID_JSON)
|
||||
if value is _INVALID_JSON:
|
||||
value = _number_or_mark(token[6])
|
||||
if deep_bad:
|
||||
record_deep(value is _INEXACT)
|
||||
else:
|
||||
keep(value)
|
||||
expect, empty = ("colon" if expect == "key" else "after"), False
|
||||
else:
|
||||
return _INVALID_JSON
|
||||
if expect != "after" or containers or JSON_SPACE.fullmatch(text, at) is None:
|
||||
return _INVALID_JSON
|
||||
return root if build else True
|
||||
|
||||
|
||||
def _json_view(text: str, size: int):
|
||||
"""A JSON text's format and view, or why it has none, as REA reads it; None when it is not JSON. Text over the
|
||||
parsed-view limit is told apart without building its value."""
|
||||
if size > MAX_PARSED_BYTES:
|
||||
valid = _read_json(text, build=False) is not _INVALID_JSON
|
||||
return {"format": "json", "view": None, "error": PARSED_TOO_LARGE} if valid else None
|
||||
# The whole text must parse, and duplicate keys settle, before an inexact number makes it JSON with an error.
|
||||
view = _read_json(text)
|
||||
if view is _INVALID_JSON:
|
||||
return None
|
||||
if _holds_inexact(view):
|
||||
return {"format": "json", "view": None,
|
||||
"error": "a number in it cannot be read exactly, such as an integer above 2^53"}
|
||||
if _deeper_than(view, MAX_JSON_VIEW_DEPTH):
|
||||
return {"format": "json", "view": None, "error": JSON_TOO_DEEP}
|
||||
return {"format": "json", "view": view, "truncated": False}
|
||||
|
||||
`;
|
||||
@@ -21,11 +21,20 @@ const PATHS = [
|
||||
];
|
||||
|
||||
it("matches the host path module for absolute local paths", () => {
|
||||
const checked: string[] = [];
|
||||
for (const path of PATHS) {
|
||||
checked.push(path);
|
||||
expect(absoluteLocalPathStringSchema.safeParse(path).success).toBe(
|
||||
isAbsolute(path),
|
||||
);
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining([
|
||||
"/absolute/path",
|
||||
"relative/path",
|
||||
"file:///absolute/path",
|
||||
]),
|
||||
);
|
||||
expect(absoluteLocalPathStringSchema.safeParse("").success).toBe(false);
|
||||
expect(
|
||||
absoluteLocalPathStringSchema.safeParse("/absolute/with\0nul").success,
|
||||
@@ -40,8 +49,18 @@ it("keeps the Windows absolute-path pattern valid in every client mode", () => {
|
||||
() => new RegExp(windowsAbsolutePathPattern.source, flags),
|
||||
flags,
|
||||
).not.toThrow();
|
||||
for (const path of PATHS)
|
||||
const checked: string[] = [];
|
||||
for (const path of PATHS) {
|
||||
checked.push(path);
|
||||
expect(windowsAbsolutePathPattern.test(path), path).toBe(
|
||||
win32.isAbsolute(path),
|
||||
);
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining([
|
||||
"C:\\absolute\\path",
|
||||
"C:relative/path",
|
||||
"/absolute/path",
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -57,6 +57,34 @@ export const nativeInstructionSchema = z.strictObject({
|
||||
"decoded is an instruction at the address; not-instruction-boundary is inside a listed instruction; data is defined data; outside-memory is outside the program's memory. Ghidra's not-in-listing means the address is in memory with no listed instruction or defined data; no decode was attempted, and memory may be uninitialized. The legacy undecodable value in older Evidence has the same ambiguous meaning and does not prove decoding failed.",
|
||||
),
|
||||
procedure: z.string().nullable(),
|
||||
containing_instruction: z
|
||||
.strictObject({
|
||||
address: z.string().min(1),
|
||||
length: z.int().positive(),
|
||||
mnemonic: z.string().min(1),
|
||||
raw_disassembly: z.string().min(1),
|
||||
provenance: z.string().min(1),
|
||||
})
|
||||
.optional()
|
||||
.describe(
|
||||
"Provider-observed listed instruction containing the exact requested interior address. Its address and length describe the containing instruction; the query address remains unchanged. Omission means not recorded or not applicable, including older Evidence. This does not imply a function, alternate decode, reachability, or runtime execution.",
|
||||
),
|
||||
containing_data: z
|
||||
.strictObject({
|
||||
address: z.string().min(1),
|
||||
length: z
|
||||
.int()
|
||||
.positive()
|
||||
.describe(
|
||||
"Observed object length in bytes; a dynamic type may not have a fixed size.",
|
||||
),
|
||||
type_id: z.string().min(1),
|
||||
provenance: z.string().min(1),
|
||||
})
|
||||
.optional()
|
||||
.describe(
|
||||
"Provider-observed containing defined data object, including its start and type identity; the selected address remains unchanged. Inspect its start with inspect_native_data_type for its layout. This does not choose a field or union member or prove runtime use. Omission means not recorded or not applicable, including older Evidence.",
|
||||
),
|
||||
architecture: z.string(),
|
||||
mode: z
|
||||
.string()
|
||||
|
||||
@@ -24,9 +24,7 @@ export const inspectSignalDecodersInputSchema = z.strictObject({
|
||||
.nonnegative()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.default(0)
|
||||
.describe(
|
||||
"Number of matching decoders or help lines to skip; defaults to 0",
|
||||
),
|
||||
.describe("Number of matching decoders or help lines to skip"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -31,7 +31,7 @@ const signalInputShape = z.strictObject({
|
||||
.int()
|
||||
.nonnegative()
|
||||
.default(0)
|
||||
.describe("Skip this many literal LF-separated output lines; default 0"),
|
||||
.describe("Skip this many literal LF-separated output lines"),
|
||||
line_limit: z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -68,7 +68,7 @@ const sharedAnalysisSemantics = {
|
||||
procedure_file_offset_resolution:
|
||||
"address-first-unique-target-source-mapping-v2",
|
||||
instruction_flow_evidence: "decoded-return-pcode-v1",
|
||||
instruction_status: "unlisted-address-without-decode-v1",
|
||||
instruction_status: "unlisted-and-containing-code-data-v3",
|
||||
instruction_references: "exact-address-incoming-without-synthetic-entry-v3",
|
||||
function_boundary_observations: "ghidra-function-boundary-limitations-v4",
|
||||
} as const;
|
||||
|
||||
@@ -522,9 +522,15 @@ describe("Ghidra compact reference provenance", () => {
|
||||
it("distinguishes incoming references, examined emptiness and unavailable coverage at data addresses", () => {
|
||||
const instruction = {
|
||||
...compactInstruction(),
|
||||
address: "ram:00402000",
|
||||
address: "ram:0x402000",
|
||||
status: "data",
|
||||
procedure: null,
|
||||
containing_data: {
|
||||
address: "ram:0x402000",
|
||||
length: 4,
|
||||
type_id: "/dword",
|
||||
provenance: "ghidra-listing",
|
||||
},
|
||||
};
|
||||
const reference = {
|
||||
...compactReference(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GHIDRA_FUNCTION_OPERATIONS } from "./GhidraFunctionOperations.js";
|
||||
import { nativeAddressObservationSchema } from "../domain/native/nativeAddress.js";
|
||||
import { addressDistance } from "../domain/native/addressCoordinate.js";
|
||||
import { procedureInfoSchema } from "../domain/native/procedureInfo.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
@@ -288,6 +289,51 @@ const ghidraFunctionInstructionWindow = functionInstructionWindowSchema
|
||||
.extend({ procedure: procedureIdentity })
|
||||
.strict();
|
||||
|
||||
// Current Ghidra profiles commit to emitting containment exactly for an interior
|
||||
// listing address. The shared Evidence schema remains optional for older records.
|
||||
const ghidraInstruction = nativeInstructionSchema.superRefine(
|
||||
(value, context) => {
|
||||
if (
|
||||
(value.status === "not-instruction-boundary") !==
|
||||
(value.containing_instruction !== undefined)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["containing_instruction"],
|
||||
message:
|
||||
"Ghidra instruction containment must match the reported status",
|
||||
});
|
||||
if ((value.status === "data") !== (value.containing_data !== undefined))
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["containing_data"],
|
||||
message: "Ghidra data containment must match the reported status",
|
||||
});
|
||||
const data = value.containing_data;
|
||||
if (data !== undefined) {
|
||||
const distance = addressDistance(data.address, value.address);
|
||||
if (distance === null || distance >= data.length)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["containing_data", "address"],
|
||||
message:
|
||||
"Ghidra containing data must enclose the queried byte in the same address space",
|
||||
});
|
||||
}
|
||||
const containing = value.containing_instruction;
|
||||
if (containing !== undefined) {
|
||||
const distance = addressDistance(containing.address, value.address);
|
||||
if (distance === null || distance === 0 || distance >= containing.length)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["containing_instruction", "address"],
|
||||
message:
|
||||
"Ghidra containing instruction must enclose the queried byte in the same address space",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const resultSchemas = {
|
||||
annotate_native_function: nativeFunctionAnnotationsSchema.safeExtend({
|
||||
dossier: ghidraFunctionDossier,
|
||||
@@ -299,7 +345,7 @@ const resultSchemas = {
|
||||
procedure_info: procedureInfo,
|
||||
procedure_pseudo_code: z.string().nullable(),
|
||||
read_function_instructions: ghidraFunctionInstructionWindow,
|
||||
inspect_native_instruction: nativeInstructionSchema,
|
||||
inspect_native_instruction: ghidraInstruction,
|
||||
inspect_native_data_type: nativeDataTypeSchema,
|
||||
resolve_native_call_targets: nativeCallTargetsSchema,
|
||||
procedure_references: procedureReferences,
|
||||
|
||||
@@ -11,9 +11,9 @@ import { GhidraProvider } from "./GhidraProvider.js";
|
||||
// bit-field base-type revisions that prevent reuse of snapshots with earlier
|
||||
// results.
|
||||
const RAW_DIGESTS = {
|
||||
"6502": "6f904539c00521b04f76a3bfd1ca88275e080b5751f9e2305de66c20d0bd2fa0",
|
||||
"6502": "6105f8d9c1739f7261a85e4a8463ed381c92ab85d6195974df73b40e45058300",
|
||||
"x86-disabled-analyzer":
|
||||
"5a534d959baf34abdafaea64ae9fcb9126553802d42ffa5e4bc0426de5f0af8c",
|
||||
"4f059962604e029c688bf6f181a8a60d6a642bd569c5e8e8ec540f888aee7de0",
|
||||
} as const;
|
||||
|
||||
const configFor = (environment: NodeJS.ProcessEnv) => {
|
||||
@@ -48,9 +48,11 @@ describe("raw-binary analysis profile digest", () => {
|
||||
load_image_evidence: "independent-raw-mapping-context-v4",
|
||||
load_image_observations:
|
||||
"source-mappings-entry-context-relocation-symbol-v3",
|
||||
instruction_status: "unlisted-address-without-decode-v1",
|
||||
instruction_status: "unlisted-and-containing-code-data-v3",
|
||||
byte_search_boundary: "contiguous-eligible-initialized-blocks-v1",
|
||||
data_type_observations: "bit-field-base-and-lookup-v1",
|
||||
instruction_references:
|
||||
"exact-address-incoming-without-synthetic-entry-v3",
|
||||
analyzer_preset: "ghidra-default",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -319,7 +319,12 @@ describe("Ghidra snapshot profile admission", () => {
|
||||
},
|
||||
{
|
||||
parameter: "instruction_status",
|
||||
current: "unlisted-address-without-decode-v1",
|
||||
current: "unlisted-and-containing-code-data-v3",
|
||||
legacy: "unlisted-address-without-decode-v1",
|
||||
},
|
||||
{
|
||||
parameter: "instruction_status",
|
||||
current: "unlisted-and-containing-code-data-v3",
|
||||
legacy: undefined,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -255,11 +255,11 @@ cliTest(
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
expect.objectContaining({
|
||||
path: ["executable_path"],
|
||||
reason: "invalid_format",
|
||||
message: expect.stringContaining("absolute local filesystem path"),
|
||||
},
|
||||
expected: "regex",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
|
||||
@@ -329,21 +330,84 @@ const pathRequests: readonly [string, Record<string, unknown>][] = [
|
||||
["start_gdb_session", { inferior_tty: nul }],
|
||||
];
|
||||
|
||||
const nulIssuePaths = (
|
||||
value: unknown,
|
||||
path: readonly (string | number)[] = [],
|
||||
): (string | number)[][] => {
|
||||
if (typeof value === "string") return value.includes("\0") ? [[...path]] : [];
|
||||
if (Array.isArray(value))
|
||||
return value.flatMap((item, index) =>
|
||||
nulIssuePaths(item, [...path, index]),
|
||||
);
|
||||
if (typeof value === "object" && value !== null)
|
||||
return Object.entries(value).flatMap(([key, item]) =>
|
||||
nulIssuePaths(item, [...path, key]),
|
||||
);
|
||||
return [];
|
||||
};
|
||||
|
||||
it("rejects OS-invalid local inputs through the real advertised MCP contracts", async () => {
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const checked: string[] = [];
|
||||
for (const [name, arguments_] of pathRequests) {
|
||||
const response = await call(name, arguments_);
|
||||
expect(response.isError, name).toBe(true);
|
||||
expect(JSON.stringify(response.content), name).toContain("NUL");
|
||||
const paths = nulIssuePaths(arguments_);
|
||||
expect(paths.length, name).toBeGreaterThan(0);
|
||||
const error = parseMcpToolError(await call(name, arguments_)).error;
|
||||
expect(error, name).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: { operation: name },
|
||||
});
|
||||
const issues = z
|
||||
.object({
|
||||
issues: z.array(
|
||||
z.object({
|
||||
path: z.array(z.union([z.string(), z.number()])),
|
||||
reason: z.string(),
|
||||
message: z.string(),
|
||||
expected: z.unknown().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.parse(error.details).issues;
|
||||
expect(issues, name).toEqual(
|
||||
expect.arrayContaining(
|
||||
paths.map((path) =>
|
||||
expect.objectContaining({
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
message: expect.stringMatching(/\bNUL\b|null byte|zero byte/i),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
checked.push(name);
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining([
|
||||
"extract_firmware",
|
||||
"inspect_wasm_artifact",
|
||||
"demangle_swift",
|
||||
"capture_electron_scenario",
|
||||
]),
|
||||
);
|
||||
const relative = await call("extract_firmware", {
|
||||
path: "/tmp/firmware.bin",
|
||||
output_directory: "relative-output",
|
||||
});
|
||||
expect(relative.isError).toBe(true);
|
||||
expect(JSON.stringify(relative.content)).toContain(
|
||||
"absolute local filesystem path",
|
||||
);
|
||||
expect(parseMcpToolError(relative).error).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "extract_firmware",
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
path: ["output_directory"],
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
const ignoredOptions = [
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createCli } from "../../../src/cli.js";
|
||||
|
||||
type CliInstance = ReturnType<typeof createCli>;
|
||||
|
||||
const serve = async (
|
||||
cli: CliInstance,
|
||||
argv: readonly string[],
|
||||
): Promise<{ readonly stdout: string; readonly exitCode: number }> => {
|
||||
let stdout = "";
|
||||
let exitCode = 0;
|
||||
await cli.serve([...argv], {
|
||||
env: {},
|
||||
exit: (code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
stdout: (text) => {
|
||||
stdout += text;
|
||||
},
|
||||
});
|
||||
return { stdout, exitCode };
|
||||
};
|
||||
|
||||
/**
|
||||
* Options whose owning field already declares a schema default. The help
|
||||
* renderer appends that default, so the description must not restate it.
|
||||
*/
|
||||
const singleDefaultOptions: readonly {
|
||||
readonly command: string;
|
||||
readonly options: readonly string[];
|
||||
}[] = [
|
||||
{ command: "inspect-byte-entropy", options: ["--block-size", "--offset"] },
|
||||
{
|
||||
command: "inspect-devicetree",
|
||||
options: [
|
||||
"--max-file-bytes",
|
||||
"--max-nodes",
|
||||
"--max-properties",
|
||||
"--max-depth",
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "decode-signal-recording",
|
||||
options: ["--line_offset", "--line_limit"],
|
||||
},
|
||||
{
|
||||
command: "inspect-packet-capture",
|
||||
options: ["--match_offset", "--match_limit"],
|
||||
},
|
||||
{
|
||||
command: "inspect-packet-fields",
|
||||
options: ["--kind", "--offset"],
|
||||
},
|
||||
{ command: "inspect-signal-decoders", options: ["--offset"] },
|
||||
];
|
||||
|
||||
const optionLine = (help: string, option: string): string | undefined => {
|
||||
const line = help
|
||||
.split(/\r?\n/u)
|
||||
.find((candidate) => candidate.trimStart().startsWith(`${option} `));
|
||||
return line?.trim();
|
||||
};
|
||||
|
||||
describe("CLI help default annotations", () => {
|
||||
it("states each schema-owned option default exactly once", async () => {
|
||||
const cli = createCli({});
|
||||
const repeated: string[] = [];
|
||||
for (const { command, options } of singleDefaultOptions) {
|
||||
const { stdout, exitCode } = await serve(cli, [command, "--help"]);
|
||||
expect(exitCode, command).toBe(0);
|
||||
for (const option of options) {
|
||||
const line = optionLine(stdout, option);
|
||||
if (line === undefined) {
|
||||
repeated.push(`${command} ${option}: option line missing`);
|
||||
continue;
|
||||
}
|
||||
const statements = line.match(/default/giu)?.length ?? 0;
|
||||
if (statements !== 1)
|
||||
repeated.push(
|
||||
`${command} ${option}: ${statements} default statements`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(repeated).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ interface PathInputCase {
|
||||
readonly field: string;
|
||||
readonly valid: Readonly<Record<string, unknown>>;
|
||||
readonly invalid: Readonly<Record<string, unknown>>;
|
||||
readonly issuePath?: readonly string[];
|
||||
readonly issuePath: readonly string[];
|
||||
}
|
||||
|
||||
const createMcp = async (
|
||||
@@ -104,7 +104,13 @@ const inputCases = (
|
||||
field: string,
|
||||
valid: Readonly<Record<string, unknown>>,
|
||||
invalid: Readonly<Record<string, unknown>>,
|
||||
): PathInputCase => ({ name, field, valid, invalid });
|
||||
): PathInputCase => ({
|
||||
name,
|
||||
field,
|
||||
valid,
|
||||
invalid,
|
||||
issuePath: field.split("."),
|
||||
});
|
||||
|
||||
return [
|
||||
pathCase("open_binary", "path", { path: absolute }, { path: relative }),
|
||||
@@ -246,8 +252,7 @@ it("advertises full host-absolute path constraints after SDK schema conversion",
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const absolute = resolve("rea-absolute-input");
|
||||
const relative = "relative-input";
|
||||
|
||||
for (const { name, field, valid, invalid } of [
|
||||
const schemaCases = [
|
||||
...inputCases(absolute, relative),
|
||||
{
|
||||
name: "inspect_go_binary",
|
||||
@@ -276,7 +281,9 @@ it("advertises full host-absolute path constraints after SDK schema conversion",
|
||||
right_bundle_path: absolute,
|
||||
},
|
||||
},
|
||||
]) {
|
||||
];
|
||||
const checked: string[] = [];
|
||||
for (const { name, field, valid, invalid } of schemaCases) {
|
||||
const schema = listed.tools.find(
|
||||
(tool) => tool.name === name,
|
||||
)?.inputSchema;
|
||||
@@ -288,7 +295,16 @@ it("advertises full host-absolute path constraints after SDK schema conversion",
|
||||
validate(valid),
|
||||
`${name}.${field}: ${JSON.stringify(validate.errors)}`,
|
||||
).toBe(true);
|
||||
checked.push(`${name}.${field}`);
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining([
|
||||
"open_binary.path",
|
||||
"extract_firmware.output_directory",
|
||||
"inspect_go_binary.path NUL",
|
||||
"compare_bundles.left_bundle_path empty",
|
||||
]),
|
||||
);
|
||||
expect(firmwareCalls).toEqual([]);
|
||||
} finally {
|
||||
await close();
|
||||
@@ -299,19 +315,39 @@ it("rejects relative paths through in-memory MCP handlers", async () => {
|
||||
const firmwareCalls: string[] = [];
|
||||
const { client, close } = await createMcp(firmwareCalls);
|
||||
try {
|
||||
for (const { name, field, invalid, issuePath } of inputCases(
|
||||
resolve("rea-absolute-input"),
|
||||
"relative-input",
|
||||
)) {
|
||||
const cases = inputCases(resolve("rea-absolute-input"), "relative-input");
|
||||
const checked: string[] = [];
|
||||
for (const { name, field, invalid, issuePath } of cases) {
|
||||
const result = await client.callTool({ name, arguments: invalid });
|
||||
expect(result.isError, `${name}.${field}`).toBe(true);
|
||||
expect(JSON.stringify(result), `${name}.${field}`).toContain("absolute");
|
||||
if (issuePath !== undefined)
|
||||
expect(parseMcpToolError(result).error.details).toMatchObject({
|
||||
operation: name,
|
||||
issues: [{ path: issuePath, reason: "invalid_format" }],
|
||||
});
|
||||
expect(parseMcpToolError(result).error, `${name}.${field}`).toMatchObject(
|
||||
{
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: name,
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
path: issuePath,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
checked.push(`${name}.${field}`);
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining([
|
||||
"open_binary.path",
|
||||
"open_binary.snapshot_path",
|
||||
"extract_firmware.path",
|
||||
"extract_firmware.output_directory",
|
||||
"inspect_analysis_view.source.path",
|
||||
"capture_browser_scenario.browser.executable_path",
|
||||
"capture_electron_scenario.application_root",
|
||||
]),
|
||||
);
|
||||
expect(firmwareCalls).toEqual([]);
|
||||
} finally {
|
||||
await close();
|
||||
|
||||
@@ -68,6 +68,12 @@ describe("compact Ghidra reference MCP evidence", () => {
|
||||
inspect_native_instruction: {
|
||||
...compactInstruction(),
|
||||
status: "data",
|
||||
containing_data: {
|
||||
address: "0x401000",
|
||||
length: 4,
|
||||
type_id: "/dword",
|
||||
provenance: "ghidra-listing",
|
||||
},
|
||||
incoming_references: [],
|
||||
},
|
||||
resolve_native_call_targets: compactCallTargets(),
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { compactInstruction } from "../../../src/domain/native/nativeInstruction.fixture.js";
|
||||
import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
|
||||
it.each(["compact", "full"] as const)(
|
||||
"keeps the selected interior address and containing instruction separate (%s)",
|
||||
async (profile) => {
|
||||
const observed = {
|
||||
...compactInstruction(),
|
||||
address: "overlay:0x1001",
|
||||
status: "not-instruction-boundary",
|
||||
procedure: null,
|
||||
bytes: null,
|
||||
length: null,
|
||||
mnemonic: null,
|
||||
raw_disassembly: null,
|
||||
operands: [],
|
||||
containing_instruction: {
|
||||
address: "overlay:0x1000",
|
||||
length: 4,
|
||||
mnemonic: "sub",
|
||||
raw_disassembly: "sub sp,sp,#0x10",
|
||||
provenance: "ghidra-listing",
|
||||
},
|
||||
};
|
||||
const harness = await connectGhidraMcp(
|
||||
"ghidra-containing-instruction",
|
||||
() => Promise.resolve(ok(observed)),
|
||||
{ environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: profile } },
|
||||
);
|
||||
try {
|
||||
const result = await harness.mcp.callTool({
|
||||
name: "inspect_native_instruction",
|
||||
arguments: { address: observed.address },
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(
|
||||
sessionEvidence(harness.session, result.structuredContent)
|
||||
.normalized_result,
|
||||
).toMatchObject({
|
||||
address: observed.address,
|
||||
status: observed.status,
|
||||
procedure: null,
|
||||
bytes: null,
|
||||
containing_instruction: observed.containing_instruction,
|
||||
});
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["missing", "unexpected"] as const)(
|
||||
"rejects a %s containing instruction at the Ghidra boundary",
|
||||
async (condition) => {
|
||||
const observed = {
|
||||
...compactInstruction(),
|
||||
address: "0x1001",
|
||||
status: condition === "missing" ? "not-instruction-boundary" : "decoded",
|
||||
...(condition === "unexpected"
|
||||
? {
|
||||
containing_instruction: {
|
||||
address: "0x1000",
|
||||
length: 4,
|
||||
mnemonic: "sub",
|
||||
raw_disassembly: "sub sp,sp,#0x10",
|
||||
provenance: "ghidra-listing",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const harness = await connectGhidraMcp("ghidra-invalid-containment", () =>
|
||||
Promise.resolve(ok(observed)),
|
||||
);
|
||||
try {
|
||||
const result = await harness.mcp.callTool({
|
||||
name: "inspect_native_instruction",
|
||||
arguments: { address: "0x1001" },
|
||||
});
|
||||
expect(parseMcpToolError(result).error).toMatchObject({
|
||||
code: "unreadable_output",
|
||||
details: { operation: "inspect_native_instruction" },
|
||||
});
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["different space", "other:0x1000"],
|
||||
["instruction start", "overlay:0x1001"],
|
||||
["past instruction", "overlay:0xff0"],
|
||||
["invalid coordinate", "not-an-address"],
|
||||
] as const)(
|
||||
"rejects a containing instruction with %s",
|
||||
async (_description, address) => {
|
||||
const observed = {
|
||||
...compactInstruction(),
|
||||
address: "overlay:0x1001",
|
||||
status: "not-instruction-boundary",
|
||||
containing_instruction: {
|
||||
address,
|
||||
length: 4,
|
||||
mnemonic: "sub",
|
||||
raw_disassembly: "sub sp,sp,#0x10",
|
||||
provenance: "ghidra-listing",
|
||||
},
|
||||
};
|
||||
const harness = await connectGhidraMcp("ghidra-invalid-coordinate", () =>
|
||||
Promise.resolve(ok(observed)),
|
||||
);
|
||||
try {
|
||||
const result = await harness.mcp.callTool({
|
||||
name: "inspect_native_instruction",
|
||||
arguments: { address: observed.address },
|
||||
});
|
||||
expect(parseMcpToolError(result).error).toMatchObject({
|
||||
code: "unreadable_output",
|
||||
details: { operation: "inspect_native_instruction" },
|
||||
});
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["compact", "full"] as const)(
|
||||
"retains a containing data identity without selecting a field (%s)",
|
||||
async (profile) => {
|
||||
const observed = {
|
||||
...compactInstruction(),
|
||||
address: "overlay:0x1002",
|
||||
status: "data",
|
||||
procedure: null,
|
||||
bytes: null,
|
||||
length: null,
|
||||
mnemonic: null,
|
||||
raw_disassembly: null,
|
||||
containing_data: {
|
||||
address: "overlay:0x1000",
|
||||
length: 5,
|
||||
type_id: "/Record",
|
||||
provenance: "ghidra-listing",
|
||||
},
|
||||
};
|
||||
const harness = await connectGhidraMcp(
|
||||
"ghidra-containing-data",
|
||||
() => Promise.resolve(ok(observed)),
|
||||
{ environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: profile } },
|
||||
);
|
||||
try {
|
||||
const result = await harness.mcp.callTool({
|
||||
name: "inspect_native_instruction",
|
||||
arguments: { address: observed.address },
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(
|
||||
sessionEvidence(harness.session, result.structuredContent)
|
||||
.normalized_result,
|
||||
).toMatchObject({
|
||||
address: observed.address,
|
||||
status: "data",
|
||||
containing_data: observed.containing_data,
|
||||
});
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["missing", undefined],
|
||||
[
|
||||
"wrong space",
|
||||
{
|
||||
address: "other:0x1000",
|
||||
length: 5,
|
||||
type_id: "/Record",
|
||||
provenance: "ghidra-listing",
|
||||
},
|
||||
],
|
||||
[
|
||||
"outside extent",
|
||||
{
|
||||
address: "overlay:0x1000",
|
||||
length: 2,
|
||||
type_id: "/Record",
|
||||
provenance: "ghidra-listing",
|
||||
},
|
||||
],
|
||||
] as const)("rejects %s data containment", async (_name, containing_data) => {
|
||||
const observed = {
|
||||
...compactInstruction(),
|
||||
address: "overlay:0x1002",
|
||||
status: "data",
|
||||
...(containing_data === undefined ? {} : { containing_data }),
|
||||
};
|
||||
const harness = await connectGhidraMcp("ghidra-invalid-data", () =>
|
||||
Promise.resolve(ok(observed)),
|
||||
);
|
||||
try {
|
||||
const result = await harness.mcp.callTool({
|
||||
name: "inspect_native_instruction",
|
||||
arguments: { address: observed.address },
|
||||
});
|
||||
expect(parseMcpToolError(result).error).toMatchObject({
|
||||
code: "unreadable_output",
|
||||
details: { operation: "inspect_native_instruction" },
|
||||
});
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects data containment on a decoded instruction", async () => {
|
||||
const observed = {
|
||||
...compactInstruction(),
|
||||
address: "0x1001",
|
||||
containing_data: {
|
||||
address: "0x1000",
|
||||
length: 4,
|
||||
type_id: "/Record",
|
||||
provenance: "ghidra-listing",
|
||||
},
|
||||
};
|
||||
const harness = await connectGhidraMcp("ghidra-unexpected-data", () =>
|
||||
Promise.resolve(ok(observed)),
|
||||
);
|
||||
try {
|
||||
const result = await harness.mcp.callTool({
|
||||
name: "inspect_native_instruction",
|
||||
arguments: { address: observed.address },
|
||||
});
|
||||
expect(parseMcpToolError(result).error).toMatchObject({
|
||||
code: "unreadable_output",
|
||||
details: { operation: "inspect_native_instruction" },
|
||||
});
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
@@ -709,8 +709,19 @@ describe("session filesystem path boundaries over MCP", () => {
|
||||
name: "export_evidence_bundle",
|
||||
arguments: { path: "relative-bundle.json" },
|
||||
});
|
||||
expect(exported.isError, JSON.stringify(exported.content)).toBe(true);
|
||||
expect(JSON.stringify(exported.content)).toContain("absolute");
|
||||
expect(parseMcpToolError(exported).error).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "export_evidence_bundle",
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const opened = await mcp.callTool({
|
||||
name: "open_binary",
|
||||
@@ -721,8 +732,19 @@ describe("session filesystem path boundaries over MCP", () => {
|
||||
name: "close_binary",
|
||||
arguments: { snapshot_path: "relative-analysis.json" },
|
||||
});
|
||||
expect(closed.isError, JSON.stringify(closed.content)).toBe(true);
|
||||
expect(JSON.stringify(closed.content)).toContain("absolute");
|
||||
expect(parseMcpToolError(closed).error).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "close_binary",
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
path: ["snapshot_path"],
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await mcp.callTool({ name: "close_binary", arguments: {} });
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
@@ -383,7 +383,7 @@ it("attributes rejected WASM paths to their selected fields before provider work
|
||||
},
|
||||
});
|
||||
onTestFinished(harness.close);
|
||||
for (const [input, paths] of [
|
||||
const relativeCases = [
|
||||
[{ path: "relative.wasm" }, [["path"]]],
|
||||
[
|
||||
{ path: value.artifact.path, glue_paths: ["relative.js"] },
|
||||
@@ -412,7 +412,9 @@ it("attributes rejected WASM paths to their selected fields before provider work
|
||||
},
|
||||
[["path"], ["glue_paths", 0], ["candidate_paths", 0]],
|
||||
],
|
||||
] as const) {
|
||||
] as const;
|
||||
const checked: string[] = [];
|
||||
for (const [input, paths] of relativeCases) {
|
||||
const response = await harness.client.callTool({
|
||||
name: "inspect_wasm_artifact",
|
||||
arguments: input,
|
||||
@@ -423,14 +425,19 @@ it("attributes rejected WASM paths to their selected fields before provider work
|
||||
.object({ issues: z.array(z.unknown()) })
|
||||
.parse(failure.details);
|
||||
expect(issues).toEqual(
|
||||
paths.map((path) => ({
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
message: "must be an absolute local filesystem path",
|
||||
})),
|
||||
paths.map((path) =>
|
||||
expect.objectContaining({
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
),
|
||||
);
|
||||
checked.push(paths.map((path) => path.join(".")).join("+"));
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining(["path", "glue_paths.0", "candidate_paths.0"]),
|
||||
);
|
||||
expect(inspect).not.toHaveBeenCalled();
|
||||
const valid = await harness.client.callTool({
|
||||
name: "inspect_wasm_artifact",
|
||||
@@ -760,12 +767,13 @@ it("rejects malformed paths, refuses WASM summary delivery before provider work
|
||||
expect(
|
||||
z.object({ issues: z.array(z.unknown()) }).parse(rejected.details).issues,
|
||||
).toEqual(
|
||||
[["path"], ["glue_paths", 0]].map((path) => ({
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
message: "must be an absolute local filesystem path",
|
||||
})),
|
||||
[["path"], ["glue_paths", 0]].map((path) =>
|
||||
expect.objectContaining({
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(inspect).not.toHaveBeenCalled();
|
||||
for (const input of [
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
PYTHON_HELPERS,
|
||||
PYTHON_IMPORTS,
|
||||
} from "../../../src/domain/desktop/appExportPython.js";
|
||||
import {
|
||||
detectStateFormat,
|
||||
textualView,
|
||||
} from "../../../src/domain/desktop/stateFormats.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const arrays = "[".repeat(1500) + "1" + "]".repeat(1500);
|
||||
const objects = '{"a":'.repeat(1500) + "1" + "}".repeat(1500);
|
||||
const cases = [
|
||||
["discarded arrays", `{"value":${arrays},"value":"done"}`, true],
|
||||
["discarded objects", `{"value":${objects},"value":"done"}`, true],
|
||||
["escaped duplicate key", `{"val\\u0075e":${arrays},"value":"done"}`, true],
|
||||
["retained deep value", `{"value":${arrays}}`, false],
|
||||
[
|
||||
"retained deep inexact number",
|
||||
`{"value":${"[".repeat(600)}9007199254740993${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep overwritten inexact number",
|
||||
`{"value":${"[".repeat(600)}{"x":9007199254740993,"x":1}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep empty-key overwrite",
|
||||
`{"value":${"[".repeat(600)}{"":9007199254740993,"":1}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep multiple inexact keys with one overwritten",
|
||||
`{"value":${"[".repeat(600)}{"a":9007199254740993,"b":9007199254740993,"a":1}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep multiple inexact keys all overwritten",
|
||||
`{"value":${"[".repeat(600)}{"a":9007199254740993,"b":9007199254740993,"a":1,"b":2}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep inexact key replaced by inexact",
|
||||
`{"value":${"[".repeat(600)}{"a":9007199254740993,"a":9007199254740995}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep escaped duplicate overwritten",
|
||||
`{"value":${"[".repeat(600)}{"\\u0061":9007199254740993,"a":1}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"discarded deep inexact subtree",
|
||||
`{"value":${"[".repeat(600)}9007199254740993${"]".repeat(600)},"value":"done"}`,
|
||||
true,
|
||||
],
|
||||
["last admitted depth", "[".repeat(500) + "1" + "]".repeat(500), true],
|
||||
["empty container at limit", "[".repeat(501) + "]".repeat(501), true],
|
||||
["empty container over limit", "[".repeat(502) + "]".repeat(502), false],
|
||||
["trailing comma", `{"value":${arrays},"value":"done",}`, false],
|
||||
["missing value", `{"value":${arrays},"value":}`, false],
|
||||
["wrong closing bracket", `{"value":${arrays},"value":"done"]`, false],
|
||||
] as const;
|
||||
|
||||
it.each(cases)("reads JSON with %s", async (_name, text, readable) => {
|
||||
const root = await createTestTempDirectory("desktop-json-depth-");
|
||||
const script = join(root, "read.py");
|
||||
const imports = PYTHON_IMPORTS.split("\n")
|
||||
.filter((line) => !line.includes("agentenv_protocol"))
|
||||
.join("\n");
|
||||
await writeFile(
|
||||
script,
|
||||
`${imports}${PYTHON_HELPERS}
|
||||
text = json.load(sys.stdin)
|
||||
print(json.dumps(_text_view("state.json", text, len(text.encode("utf-8")))))
|
||||
`,
|
||||
);
|
||||
const output = JSON.parse(
|
||||
execFileSync(
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
["-I", "-B", "-X", "utf8", script],
|
||||
{ input: JSON.stringify(text), encoding: "utf8" },
|
||||
),
|
||||
) as { format: string; view: unknown; error?: string };
|
||||
const bytes = Buffer.from(text);
|
||||
const format = detectStateFormat("state.json", bytes);
|
||||
if (format !== "json" && format !== "text")
|
||||
throw new Error(`Unexpected native format ${format}`);
|
||||
const native = textualView(format, bytes);
|
||||
expect(format === "json" && native.ok).toBe(readable);
|
||||
expect(output.format).toBe(format);
|
||||
if (native.ok) {
|
||||
expect(output.error).toBeUndefined();
|
||||
// Compare JSON values on their wire form, independent of object prototypes.
|
||||
expect(JSON.stringify(output.view)).toBe(JSON.stringify(native.value));
|
||||
if (
|
||||
readable &&
|
||||
(_name.startsWith("discarded") || _name === "escaped duplicate key")
|
||||
)
|
||||
expect(output.view).toEqual({ value: "done" });
|
||||
} else {
|
||||
expect(output.view).toBeNull();
|
||||
expect(output.error).toBe(native.error);
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds construction before refusing retained excessive JSON depth", async () => {
|
||||
const root = await createTestTempDirectory("desktop-json-construction-");
|
||||
const script = join(root, "bounded.py");
|
||||
const imports = PYTHON_IMPORTS.split("\n")
|
||||
.filter((line) => !line.includes("agentenv_protocol"))
|
||||
.join("\n");
|
||||
await writeFile(
|
||||
script,
|
||||
`${imports}${PYTHON_HELPERS}
|
||||
import tracemalloc
|
||||
text = "[" * 20_000 + "0" + "]" * 20_000
|
||||
recursion_limit = sys.getrecursionlimit()
|
||||
tracemalloc.start()
|
||||
entry = _json_view(text, len(text))
|
||||
peak = tracemalloc.get_traced_memory()[1]
|
||||
tracemalloc.stop()
|
||||
print(json.dumps({"entry": entry, "peak": peak, "recursion_unchanged": sys.getrecursionlimit() == recursion_limit}))
|
||||
`,
|
||||
);
|
||||
const output = JSON.parse(
|
||||
execFileSync(
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
["-I", "-B", "-X", "utf8", script],
|
||||
{ encoding: "utf8" },
|
||||
),
|
||||
) as { entry: unknown; peak: number; recursion_unchanged: boolean };
|
||||
expect(output.entry).toEqual({
|
||||
format: "json",
|
||||
view: null,
|
||||
error: "nested deeper than the 500 levels a JSON view keeps",
|
||||
});
|
||||
expect(output.recursion_unchanged).toBe(true);
|
||||
// Python heap only, excluding source construction and interpreter/OS memory.
|
||||
// A value retained only to refuse it must not allocate every input container.
|
||||
expect(output.peak).toBeLessThan(1024 * 1024);
|
||||
});
|
||||
|
||||
it("bounds allocations when a very deep value is overwritten", async () => {
|
||||
const root = await createTestTempDirectory("desktop-json-memory-");
|
||||
const script = join(root, "read.py");
|
||||
const imports = PYTHON_IMPORTS.split("\n")
|
||||
.filter((line) => !line.includes("agentenv_protocol"))
|
||||
.join("\n");
|
||||
await writeFile(
|
||||
script,
|
||||
`${imports}\nimport tracemalloc\n${PYTHON_HELPERS}
|
||||
text = '{"value":' + '[' * 200000 + '1' + ']' * 200000 + ',"value":"done"}'
|
||||
tracemalloc.start()
|
||||
result = _json_view(text, len(text))
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
print(json.dumps({"view": result["view"], "peak": peak}))
|
||||
`,
|
||||
);
|
||||
const measured = JSON.parse(
|
||||
execFileSync(
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
["-I", "-B", script],
|
||||
{ encoding: "utf8", timeout: 30000 },
|
||||
),
|
||||
) as { view: unknown; peak: number };
|
||||
expect(measured.view).toEqual({ value: "done" });
|
||||
expect(measured.peak).toBeLessThan(8 * 1024 * 1024);
|
||||
}, 40000);
|
||||
|
||||
it("bounds a wide container already beyond the retained view depth", async () => {
|
||||
const root = await createTestTempDirectory("desktop-json-wide-depth-");
|
||||
const script = join(root, "read.py");
|
||||
const imports = PYTHON_IMPORTS.split("\n")
|
||||
.filter((line) => !line.includes("agentenv_protocol"))
|
||||
.join("\n");
|
||||
await writeFile(
|
||||
script,
|
||||
`${imports}\nimport tracemalloc\n${PYTHON_HELPERS}
|
||||
text = '[' * 501 + ','.join(['"abc"'] * 600000) + ']' * 501
|
||||
tracemalloc.start()
|
||||
result = _json_view(text, len(text))
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
print(json.dumps({"error": result.get("error"), "peak": peak}))
|
||||
`,
|
||||
);
|
||||
const measured = JSON.parse(
|
||||
execFileSync(
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
["-I", "-B", script],
|
||||
{ encoding: "utf8", timeout: 30000 },
|
||||
),
|
||||
) as { error: string; peak: number };
|
||||
expect(measured.error).toBe(
|
||||
"nested deeper than the 500 levels a JSON view keeps",
|
||||
);
|
||||
expect(measured.peak).toBeLessThan(8 * 1024 * 1024);
|
||||
}, 40000);
|
||||
@@ -34,7 +34,7 @@ const execPython = (
|
||||
try {
|
||||
writeFileSync(script, args[codeIndex + 1]!, "utf8");
|
||||
return execFileSync(
|
||||
"python3",
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
[
|
||||
"-X",
|
||||
"utf8",
|
||||
@@ -375,15 +375,16 @@ it("checks text for JSON the way REA does, token by token, without parsing it",
|
||||
"-I",
|
||||
"-B",
|
||||
"-c",
|
||||
`${imports}${PYTHON_HELPERS}\nprint(json.dumps([_is_json(text) for text in json.load(sys.stdin)]))`,
|
||||
`${imports}${PYTHON_HELPERS}\ntexts = json.load(sys.stdin)\nprint(json.dumps({"syntax": [_read_json(text, build=False) is not _INVALID_JSON for text in texts], "built": [_read_json(text) is not _INVALID_JSON for text in texts]}))`,
|
||||
],
|
||||
{ input: JSON.stringify(texts), encoding: "utf8" },
|
||||
),
|
||||
) as boolean[];
|
||||
) as { syntax: boolean[]; built: boolean[] };
|
||||
|
||||
const parsed = texts.map((text) => safeParseJson(text).ok);
|
||||
expect(texts.map((text) => isJsonText(text))).toEqual(parsed);
|
||||
expect(python).toEqual(parsed);
|
||||
expect(python.syntax).toEqual(parsed);
|
||||
expect(python.built).toEqual(parsed);
|
||||
expect(parsed.filter(Boolean)).toHaveLength(10);
|
||||
});
|
||||
|
||||
@@ -445,9 +446,13 @@ print(json.dumps([_text_view("settings.ini", text, len(text.encode()))["view"]["
|
||||
const root = await createTestTempDirectory("rea-keyfile-python-");
|
||||
const script = join(root, "fixture.py");
|
||||
await writeFile(script, program, "utf8");
|
||||
const output = execFileSync("python3", ["-I", "-B", "-X", "utf8", script], {
|
||||
input: JSON.stringify(inputs),
|
||||
encoding: "utf8",
|
||||
});
|
||||
const output = execFileSync(
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
["-I", "-B", "-X", "utf8", script],
|
||||
{
|
||||
input: JSON.stringify(inputs),
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(JSON.parse(output)).toEqual(inputs.map(keyfileView));
|
||||
});
|
||||
Reference in new issue
Block a user