diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py index 166f530a1..8f3397e3e 100644 --- a/bridge/hopper_bridge.py +++ b/bridge/hopper_bridge.py @@ -191,12 +191,41 @@ def _document(name=None): return session_document +def _api_text(value, field): + """Validate text before passing it to Hopper's native UTF-8 string API.""" + if not isinstance(value, str): + raise InvalidRequestError("%s must be a string" % field) + if "\0" in value: + raise InvalidRequestError("%s contains a NUL character, which Hopper's Python API cannot represent" % field) + try: + value.encode("utf-8") + except UnicodeEncodeError as error: + raise InvalidRequestError("%s contains an unpaired Unicode surrogate, which Hopper's Python API cannot represent" % field) from error + return value + + +def _validate_name_targets(document, names): + """Keep Hopper's unique-label moves within the explicitly selected addresses.""" + targets = {address for _, address, _ in names} + if len(targets) != len(names): + raise InvalidRequestError("Batch rename selects the same address more than once; use one canonical address per target") + labels = set() + for _, address, name in names: + if not name: + continue + if name in labels: + raise InvalidRequestError("Batch rename assigns the same name more than once: %s" % name) + labels.add(name) + owner = document.getAddressForName(name) + if owner not in BAD_ADDRESSES and owner != address and owner not in targets: + raise InvalidRequestError("Name %s is already assigned to %s; include that address in the batch with a replacement name" % (name, _hex(owner))) + + def _address(document, value=None): """Keep explicit hexadecimal coordinates distinct from symbol names.""" if value is None: return document.getCurrentAddress() - if not isinstance(value, str): - raise InvalidRequestError("Address must be a string") + _api_text(value, "Address") if value.lower().startswith("0x"): try: address = int(value, 16) @@ -250,14 +279,18 @@ def _procedure_identity(procedure): def _procedure_locals(procedure): - """Project opaque Hopper local-variable objects into an exact public shape.""" - return [ - { - "description": str(local), + """Preserve public local-variable names and observed stack displacements.""" + result = [] + for local in procedure.getLocalVariableList(): + name = local.name() + displacement = local.displacement() + result.append({ + "description": "%s (stack displacement %s)" % (name if name is not None else "Unnamed local", displacement), + "name": name, + "stack_displacement": str(displacement) if displacement is not None else None, "provenance": "hopper-public-python-api", - } - for local in procedure.getLocalVariableList() - ] + }) + return result def _containing_procedure(document, address): @@ -705,8 +738,13 @@ def _dispatch(method, params): return _procedure_name(_procedure(document)) if method == "goto_address": address = _address(document, params.get("address")) + segment = _segment(document, address) + expected = segment.getInstructionStart(address) document.moveCursorAtAddress(address) - return _hex(address) + observed = document.getCurrentAddress() + if observed != (address if expected in BAD_ADDRESSES else expected): + raise InvalidRequestError("Hopper cursor did not reach the requested object at %s; observed %s" % (_hex(address), _hex(observed))) + return _hex(observed) if method in ("address_name", "comment", "inline_comment", "xrefs"): target = _address(document, params.get("address")) segment = _segment(document, target) @@ -719,12 +757,22 @@ def _dispatch(method, params): return [_hex(value) for value in segment.getReferencesOfAddress(target)] if method in ("next_address", "prev_address"): target = _address(document, params.get("address")) + segment = _segment(document, target) if method == "next_address": - result = target + max(1, document.getObjectLength(target)) + start = segment.getInstructionStart(target) + if start in BAD_ADDRESSES: + raise InvalidRequestError("No analyzed object exists at the requested address") + length = segment.getObjectLength(start) + if length in BAD_ADDRESSES or length <= 0: + raise InvalidRequestError("No next analyzed object exists at the requested address") + result = start + length else: - result = document.getInstructionStart(max(0, target - 1)) - if result in BAD_ADDRESSES: - raise InvalidRequestError("No adjacent address") + previous_segment = document.getSegmentAtAddress(target - 1) if target > 0 else None + if previous_segment is None: + raise InvalidRequestError("No previous address exists in mapped memory") + result = previous_segment.getInstructionStart(target - 1) + if result in BAD_ADDRESSES or document.getSegmentAtAddress(result) is None: + raise InvalidRequestError("No adjacent analyzed object exists in mapped memory") return _hex(result) if method == "list_segments": result = [] @@ -801,8 +849,10 @@ def _dispatch(method, params): } if method == "set_address_name": address = _address(document, params.get("address")) + name = _api_text(params["name"], "Name") + _validate_name_targets(document, [(params.get("address"), address, name)]) try: - result = document.setNameAtAddress(address, params["name"]) + result = document.setNameAtAddress(address, name) finally: _invalidate_search_inventory(document) _invalidate_pseudocode(document) @@ -810,7 +860,8 @@ def _dispatch(method, params): if method == "set_addresses_names": # Validate all destinations before applying any annotation. A malformed # later address must not discard the result of an earlier mutation. - names = [(key, _address(document, key), value) for key, value in params["names"].items()] + names = [(key, _address(document, key), _api_text(value, "Name for %s" % key)) for key, value in params["names"].items()] + _validate_name_targets(document, names) try: result = {key: document.setNameAtAddress(address, value) for key, address, value in names} finally: @@ -819,18 +870,22 @@ def _dispatch(method, params): return result if method in ("set_comment", "set_inline_comment"): address = _address(document, params.get("address")) + comment = _api_text(params["comment"], "Comment") segment = _segment(document, address) setter = segment.setCommentAtAddress if method == "set_comment" else segment.setInlineCommentAtAddress getter = segment.getCommentAtAddress if method == "set_comment" else segment.getInlineCommentAtAddress - setter(address, params["comment"]) + setter(address, comment) _invalidate_pseudocode(document) observed = getter(address) - return observed == params["comment"] or (params["comment"] == "" and observed is None) + return observed == comment or (comment == "" and observed is None) if method == "list_bookmarks": return [{"address": _hex(item), "name": document.getBookmarkName(item)} for item in document.getBookmarks()] if method == "set_bookmark": address = _address(document, params.get("address")) - document.setBookmarkAtAddress(address, params.get("name")) + name = params.get("name") + if name is not None: + _api_text(name, "Bookmark name") + document.setBookmarkAtAddress(address, name) return document.hasBookmarkAtAddress(address) if method == "unset_bookmark": address = _address(document, params.get("address")) diff --git a/docs/installation.md b/docs/installation.md index 8b37f96d8..dec6f60d0 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -319,6 +319,11 @@ document, even when GUI focus changes or other documents have the same display name. Use `open_binary` to change targets. Byte reads stop at a segment boundary and return the readable prefix with `complete: false`. File-offset mapping checks the reverse lookup; synthetic external-symbol memory has no original file offset. +Cursor navigation returns the observed object start when Hopper snaps an interior +address; adjacent-object navigation rejects unmapped inputs and document ends. +Native API text rejects NUL characters and unpaired Unicode surrogates before +annotation changes. Renames preserve unselected label owners; use a batch with +all affected addresses to move or swap existing labels explicitly. Closing or switching a target closes its bound Hopper document, shuts down REA's bridge and removes its temporary socket directory while preserving the Hopper diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index 6a3300833..82502e211 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -91,7 +91,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "goto_address", - "Move Hopper's GUI cursor to a hexadecimal address and return the resolved address. This changes navigation state but not analysis data; use explicit-address tools for headless workflows.", + "Move Hopper's GUI cursor to a mapped address and return the observed cursor address. Interior coordinates snap to the containing analyzed object's start. Unmapped destinations fail without moving the cursor. This changes navigation state but not analysis data; use explicit-address tools for headless workflows.", z.object({ address, document }), ), official( @@ -136,7 +136,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "prev_address", - "Return the previous analyzed instruction start before an explicit address or current cursor. This is a navigation primitive and may fail at document boundaries.", + "Return the previous analyzed object start before an explicit address or current cursor. This is a navigation primitive and may fail at document boundaries; data objects and instructions remain distinct from control flow.", z.object({ document, address: optionalAddress }), ), official( @@ -161,7 +161,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "procedure_info", - "Return provider metadata for one procedure identified by symbol or address: entrypoint, signature, locals, size, block count, and complete inclusive body ranges when observed. Body byte count and enclosing span remain distinct; unavailable extent is explicit.", + "Return provider metadata for one procedure identified by symbol or address: entrypoint, signature, locals, size, block count, and complete inclusive body ranges when observed. Hopper locals preserve observed names and signed stack displacements; source types remain unknown. Body byte count and enclosing span remain distinct; unavailable extent is explicit.", z.object({ procedure, document }), ), official( @@ -203,12 +203,12 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "set_address_name", - "Assign an analyst name to one hexadecimal address and report Hopper's boolean result. This mutates analysis metadata.", + "Assign an analyst name to one hexadecimal address and report Hopper's boolean result. Reject names owned by another address; use set_addresses_names to explicitly move or swap labels among selected addresses. This mutates analysis metadata.", z.object({ address, name: z.string(), document }), ), official( "set_addresses_names", - "Assign analyst names to multiple addresses in one call and return per-address success booleans. Hopper validates every address before applying names. This mutates analysis metadata; verify failures individually.", + "Assign analyst names to multiple addresses in one call and return per-address success booleans. Hopper validates addresses, native string representations, and unique destinations/names before applying edits. Existing labels may move or swap only among explicitly selected addresses; unselected label owners remain unchanged. This mutates analysis metadata; verify failures individually.", z.object({ names: z.record(z.string(), z.string()), document }), ), official( diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index 0b852d993..9c3bbb208 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -212,6 +212,15 @@ export const localVariableSchema = z .object({ description: z.string(), provenance: z.string().min(1), + name: z.string().nullable().exactOptional(), + stack_displacement: z + .string() + .regex(/^-?\d+$/u) + .nullable() + .exactOptional() + .describe( + "Observed signed stack byte displacement, encoded as decimal to preserve precision.", + ), }) .strict(); /** Provider-neutral complete raw-instruction list for one analyzed function. */