2026-07-12 08:45:35 +08:00
"""Authenticated REA adapter executed on Hopper's dedicated Python thread.
The bootstrap injects ``REA_SOCKET`` and a random ``REA_TOKEN`` before executing
this file with Hopper's supported ``--python`` launcher option. Keep all Hopper
API access on this thread: moving dispatch to a worker can deadlock Hopper.
"""
2026-07-12 03:25:18 +08:00
import json
import hmac
import os
import socket
2026-07-24 13:14:01 +00:00
from typing import Any , Optional , Protocol , Sequence
2026-07-12 03:25:18 +08:00
BAD_ADDRESSES = ( - 1 , 0xFFFFFFFFFFFFFFFF , None )
2026-10-09 03:38:55 +08:00
# The native label setter truncates NSString names at this UTF-16 extent.
HOPPER_SYMBOL_NAME_UTF16_UNITS = 1024
2026-09-29 07:08:46 +08:00
_rea_session_document = None
2026-07-12 21:12:18 +00:00
_search_inventory_cache = {}
2026-07-23 23:18:17 +08:00
_pseudocode_cache = {}
2026-07-24 13:14:01 +00:00
_hopper_api = None
2026-07-14 04:18:26 +08:00
2026-07-24 13:14:01 +00:00
class HopperDocument ( Protocol ):
"""Typed minimum used by the provider-neutral Hopper API facade."""
def getDocumentName ( self ) -> str : ...
def getExecutableFilePath ( self ) -> Optional [ str ]: ...
def getDatabaseFilePath ( self ) -> Optional [ str ]: ...
def backgroundProcessActive ( self ) -> bool : ...
class HopperDocumentProvider ( Protocol ):
"""Hopper's injected global document provider."""
@classmethod
def getAllDocuments ( cls ) -> Sequence [ HopperDocument ]: ...
@classmethod
def getCurrentDocument ( cls ) -> Optional [ HopperDocument ]: ...
class HopperApiFacade :
"""Small injected boundary around Hopper globals for standalone tests."""
def __init__ ( self , document_provider : HopperDocumentProvider ):
self . _document_provider = document_provider
def documents ( self ) -> Sequence [ HopperDocument ]:
return self . _document_provider . getAllDocuments ()
def current_document ( self ) -> Optional [ HopperDocument ]:
return self . _document_provider . getCurrentDocument ()
def require_analysis_complete (
self , document : HopperDocument , operation : str
) -> None :
if document . backgroundProcessActive ():
raise CapabilityUnavailableError (
" %s requires completed Hopper background analysis" % operation
)
def _configure_hopper_api ( document_provider ):
"""Inject Hopper's document provider or a standalone test facade."""
2026-09-29 07:08:46 +08:00
global _hopper_api , _rea_session_document
2026-07-24 13:14:01 +00:00
_hopper_api = HopperApiFacade ( document_provider )
2026-09-29 07:08:46 +08:00
_rea_session_document = None
2026-07-24 13:14:01 +00:00
def _api ():
"""Resolve Hopper globals lazily so importing this module needs no Hopper."""
global _hopper_api
if _hopper_api is not None :
return _hopper_api
document_provider = globals () . get ( "Document" )
if document_provider is None :
raise CapabilityUnavailableError (
"Hopper Document API is unavailable outside the Hopper runtime"
)
_hopper_api = HopperApiFacade ( document_provider )
return _hopper_api
2026-07-14 04:18:26 +08:00
def _session_document ():
2026-09-29 07:08:46 +08:00
"""Return the exact document bound to this authenticated REA session."""
global _rea_session_document
documents = _api () . documents ()
if _rea_session_document is not None :
2026-10-09 02:04:29 +08:00
# Hopper recreates Python wrappers; equality compares native handles.
# Retain our bound wrapper so cache identity stays stable across calls.
2026-09-29 07:08:46 +08:00
return (
_rea_session_document
2026-10-09 02:04:29 +08:00
if any ( document == _rea_session_document for document in documents )
2026-09-29 07:08:46 +08:00
else None
)
return _bind_session_document ( documents )
def _bind_session_document ( documents = None ):
"""Bind this bridge to Hopper's current document for its target."""
global _rea_session_document
api = _api ()
if documents is None :
documents = api . documents ()
2026-07-14 04:18:26 +08:00
target = os . path . realpath ( REA_TARGET_PATH )
2026-09-29 07:08:46 +08:00
def matches_target ( document ):
paths = ( document . getExecutableFilePath (), document . getDatabaseFilePath ())
return any ( path and os . path . realpath ( path ) == target for path in paths )
2026-07-14 04:18:26 +08:00
2026-09-29 07:08:46 +08:00
current = api . current_document ()
if current is not None and matches_target ( current ):
_rea_session_document = current
return current
matching = [ document for document in documents if matches_target ( document )]
if len ( matching ) == 1 :
_rea_session_document = matching [ 0 ]
return matching [ 0 ]
if len ( matching ) > 1 :
raise CapabilityUnavailableError (
"The REA session document is ambiguous; Hopper did not identify the launched document"
)
return None
2026-07-12 03:25:18 +08:00
2026-07-24 13:14:01 +00:00
class CapabilityUnavailableError ( Exception ):
"""The active Hopper build lacks one admitted public API operation."""
2026-10-04 17:25:09 +08:00
class InvalidRequestError ( Exception ):
"""Caller input failed bridge request or operation validation."""
2026-07-24 13:14:01 +00:00
EXHAUSTIVE_ANALYSIS_METHODS = frozenset (
(
"analyze_function" ,
"list_names" ,
"list_procedures" ,
"list_strings" ,
"procedure_address" ,
"procedure_assembly" ,
"procedure_callees" ,
"procedure_callers" ,
"procedure_info" ,
"procedure_pseudo_code" ,
"procedure_references" ,
"read_function_instructions" ,
"resolve_containing_procedure" ,
"search_procedures" ,
"search_strings" ,
)
)
2026-07-12 03:25:18 +08:00
def _hex ( value ):
2026-10-04 16:02:36 +08:00
if not isinstance ( value , int ) or isinstance ( value , bool ):
try :
value = int ( str ( value ), 16 )
except ValueError as error :
raise ValueError ( "Hopper returned a non-hexadecimal address" ) from error
2026-07-12 03:25:18 +08:00
return "0x %x " % value
def _json_safe ( value ):
2026-07-12 08:45:35 +08:00
"""Project Hopper-specific Python values into the JSON protocol boundary."""
2026-07-12 03:25:18 +08:00
if value is None or isinstance ( value , ( bool , int , float , str )):
return value
if isinstance ( value , ( list , tuple )):
return [ _json_safe ( item ) for item in value ]
if isinstance ( value , dict ):
return { str ( key ): _json_safe ( item ) for key , item in value . items ()}
return str ( value )
def _document ( name = None ):
2026-10-09 02:04:29 +08:00
"""Keep observations and mutations bound to the active REA target."""
documents = _api () . documents ()
session_document = _session_document ()
if session_document is None :
raise CapabilityUnavailableError ( "The active REA target's Hopper document is no longer open; reopen the target with open_binary" )
2026-07-12 03:25:18 +08:00
if name is not None :
2026-10-09 02:04:29 +08:00
# A document name is display metadata; scope it to the bound native
# handle even when another open target happens to have the same name.
if session_document . getDocumentName () == name :
return session_document
if any ( candidate . getDocumentName () == name for candidate in documents ):
raise InvalidRequestError ( "Hopper document belongs to another target; use open_binary to select that target" )
2026-10-04 17:25:09 +08:00
raise InvalidRequestError ( "Unknown Hopper document" )
2026-10-09 02:04:29 +08:00
return session_document
2026-07-12 03:25:18 +08:00
2026-10-09 02:42:25 +08:00
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 :
2026-10-09 03:38:55 +08:00
_segment ( document , address )
2026-10-09 02:42:25 +08:00
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 )))
2026-10-09 03:38:55 +08:00
def _symbol_name ( value , field ):
"""Reject native label truncation without altering the caller's name."""
value = _api_text ( value , field )
if len ( value . encode ( "utf-16-le" )) // 2 > HOPPER_SYMBOL_NAME_UTF16_UNITS :
raise InvalidRequestError ( " %s exceeds Hopper's %s UTF-16 code-unit symbol-name limit" % ( field , HOPPER_SYMBOL_NAME_UTF16_UNITS ))
return value
def _name_matches ( document , address , name ):
observed = _segment ( document , address ) . getNameAtAddress ( address )
return observed == name or ( not name and observed is None )
2026-10-20 22:39:23 +08:00
def _bookmark_name ( document , address ):
getter = getattr ( document , "getBookmarkName" , None )
if not callable ( getter ):
raise CapabilityUnavailableError (
"Hopper's public Python API does not expose getBookmarkName"
)
return getter ( address )
2026-07-12 03:25:18 +08:00
def _address ( document , value = None ):
2026-10-09 02:04:29 +08:00
"""Keep explicit hexadecimal coordinates distinct from symbol names."""
2026-07-12 03:25:18 +08:00
if value is None :
return document . getCurrentAddress ()
2026-10-09 02:42:25 +08:00
_api_text ( value , "Address" )
2026-10-09 02:04:29 +08:00
if value . lower () . startswith ( "0x" ):
try :
address = int ( value , 16 )
except ValueError as error :
raise InvalidRequestError ( "Invalid hexadecimal Hopper address: %s " % value ) from error
else :
# Names such as `add` and `face` are valid hexadecimal strings too.
# Prefer an observed symbol, retaining bare-hex compatibility only
# when Hopper has no symbol with the supplied name.
address = document . getAddressForName ( value )
if address in BAD_ADDRESSES :
try :
address = int ( value , 16 )
except ValueError as error :
raise InvalidRequestError ( "Unknown Hopper address or name: %s " % value ) from error
if not 0 <= address < 0xFFFFFFFFFFFFFFFF :
raise InvalidRequestError ( "Address is outside Hopper's usable unsigned 64-bit range: %s " % value )
return address
2026-07-12 03:25:18 +08:00
def _segment ( document , address ):
result = document . getSegmentAtAddress ( address )
if result is None :
2026-10-04 17:25:09 +08:00
raise InvalidRequestError ( "Address is outside every segment" )
2026-07-12 03:25:18 +08:00
return result
def _procedure ( document , value = None ):
address = document . getCurrentAddress () if value is None else _address ( document , value )
result = _segment ( document , address ) . getProcedureAtAddress ( address )
if result is None :
2026-10-04 17:25:09 +08:00
raise InvalidRequestError ( "No procedure exists at the requested address" )
2026-07-12 03:25:18 +08:00
return result
def _procedure_name ( procedure ):
entry = procedure . getEntryPoint ()
return procedure . getSegment () . getNameAtAddress ( entry ) or _hex ( entry )
2026-07-12 19:32:13 +08:00
def _procedure_identity ( procedure ):
2026-07-16 03:09:34 +08:00
return {
"address" : _hex ( procedure . getEntryPoint ()),
"name" : _procedure_name ( procedure ),
"classification" : None ,
2026-10-05 21:15:04 +08:00
"body" : {
"available" : False ,
"reason" : "Hopper's public Python API does not expose complete function body ranges" ,
},
2026-07-16 03:09:34 +08:00
}
2026-07-12 19:32:13 +08:00
2026-07-12 21:12:18 +00:00
def _procedure_locals ( procedure ):
2026-10-09 02:42:25 +08:00
"""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 ,
2026-07-12 21:12:18 +00:00
"provenance" : "hopper-public-python-api" ,
2026-10-09 02:42:25 +08:00
})
return result
2026-07-12 21:12:18 +00:00
2026-07-12 19:32:13 +08:00
def _containing_procedure ( document , address ):
segment = document . getSegmentAtAddress ( address )
if segment is None :
return None , "outside_segments"
procedure = segment . getProcedureAtAddress ( address )
return ( procedure , None ) if procedure is not None else ( None , "not_in_procedure" )
2026-10-09 05:31:25 +08:00
def _basic_block_end ( procedure , block ):
"""Normalize native block endpoints to an exclusive byte address."""
2026-10-09 00:51:53 +02:00
owner_at = getattr ( procedure , "getBasicBlockAtAddress" , None )
if not callable ( owner_at ):
raise CapabilityUnavailableError (
"Instruction enumeration requires Hopper basic-block ownership"
)
2026-10-09 05:31:25 +08:00
end = block . getEndingAddress ()
# Hopper 6.1 returns the final instruction address, despite the Python
# documentation describing an exclusive end. Membership distinguishes
# that representation from an actual exclusive endpoint in other builds.
2026-10-09 00:51:53 +02:00
if owner_at ( end ) == block :
2026-10-09 05:31:25 +08:00
instruction = procedure . getSegment () . getInstructionAtAddress ( end )
if instruction is not None and instruction . getInstructionLength () > 0 :
return end + instruction . getInstructionLength ()
return end
2026-10-09 00:51:53 +02:00
def _block_instruction_addresses ( procedure , block ):
"""Walk either endpoint convention using Hopper's block ownership API.
Some Hopper builds return the final instruction start, despite the public
API documenting an exclusive end. Equality compares fresh native wrappers;
probing ownership also keeps an exclusive endpoint in the next block out.
"""
segment = procedure . getSegment ()
address = block . getStartingAddress ()
end = _basic_block_end ( procedure , block )
while address < end :
if procedure . getBasicBlockAtAddress ( address ) != block :
break
instruction = segment . getInstructionAtAddress ( address )
if instruction is None :
break
length = instruction . getInstructionLength ()
if length <= 0 or address + length > end :
break
yield address
address += length
2026-09-29 11:22:31 +08:00
def _instruction_addresses ( procedure ):
2026-07-12 19:32:13 +08:00
result = []
seen = set ()
for block in procedure . basicBlockIterator ():
2026-10-09 00:51:53 +02:00
for address in _block_instruction_addresses ( procedure , block ):
if address in seen :
continue
2026-07-12 19:32:13 +08:00
seen . add ( address )
result . append ( address )
2026-09-29 11:22:31 +08:00
return result
2026-07-12 19:32:13 +08:00
2026-10-09 05:05:39 +08:00
def _native_calls ( procedure , direction ):
references = procedure . getAllCallees () if direction == "outgoing" else procedure . getAllCallers ()
kinds = { 0 : "none" , 1 : "unknown" , 2 : "direct" , 3 : "objective_c" }
calls = {}
unresolved = []
for reference in references :
source , target , kind = reference . fromAddress (), reference . toAddress (), reference . type ()
if source in BAD_ADDRESSES :
continue
if target in BAD_ADDRESSES :
unresolved . append ({ "address" : _hex ( source ), "reason" : "Native CallReference has no resolved target (type %s )" % kind })
continue
calls [( source , target )] = { "classification" : kinds . get ( kind , "unknown" ), "provider_type" : kind , "provenance" : "hopper-public-python-api:CallReference" }
return calls , sorted ( unresolved , key = lambda item : int ( item [ "address" ], 16 ))
2026-07-12 19:32:13 +08:00
def _procedure_references ( document , params ):
procedure = _procedure ( document , params . get ( "procedure" ))
direction = params . get ( "direction" , "outgoing" )
if direction not in ( "incoming" , "outgoing" ):
2026-10-04 17:25:09 +08:00
raise InvalidRequestError ( "direction must be incoming or outgoing" )
2026-09-29 11:22:31 +08:00
addresses = _instruction_addresses ( procedure )
2026-07-12 19:32:13 +08:00
edges = set ()
for address in addresses :
segment = _segment ( document , address )
references = segment . getReferencesFromAddress ( address ) if direction == "outgoing" else segment . getReferencesOfAddress ( address )
for reference in references :
edges . add (( address , reference ) if direction == "outgoing" else ( reference , address ))
2026-10-09 05:05:39 +08:00
calls , unresolved = _native_calls ( procedure , direction )
edges . update ( calls )
2026-07-12 19:32:13 +08:00
ordered = sorted ( edges )
items = []
2026-09-29 07:08:46 +08:00
for source , target in ordered :
2026-07-12 19:32:13 +08:00
source_procedure , _ = _containing_procedure ( document , source )
target_procedure , _ = _containing_procedure ( document , target )
items . append ({
"source_address" : _hex ( source ),
"target_address" : _hex ( target ),
"source_procedure" : _procedure_identity ( source_procedure ) if source_procedure is not None else None ,
"target_procedure" : _procedure_identity ( target_procedure ) if target_procedure is not None else None ,
2026-10-09 05:05:39 +08:00
"kind" : _unavailable ( "Hopper exposes partial CallReference classification, but not detailed reference flags" ),
** ({ "call" : calls [( source , target )]} if ( source , target ) in calls else {}),
2026-07-12 19:32:13 +08:00
})
return {
"procedure" : _procedure_identity ( procedure ), "direction" : direction ,
2026-10-05 20:16:44 +08:00
"reference_kinds_available" : False ,
2026-10-09 05:05:39 +08:00
# These are native CallReference objects, not proof that every unresolved
# dispatch site has been enumerated by Hopper.
"unresolved_calls" : unresolved ,
2026-09-29 07:08:46 +08:00
"references" : items ,
2026-07-12 19:32:13 +08:00
}
2026-07-12 03:25:18 +08:00
def _procedure_map ( document ):
result = {}
for segment in document . getSegmentsList ():
for index in range ( segment . getProcedureCount ()):
procedure = segment . getProcedureAtIndex ( index )
result [ _hex ( procedure . getEntryPoint ())] = _procedure_name ( procedure )
return result
2026-10-09 04:07:47 +08:00
def _string_record ( segment , address , display ):
"""Read one native typed object, retaining its shortened display separately."""
length = segment . getObjectLength ( address )
end = segment . getStartingAddress () + segment . getLength ()
if isinstance ( length , bool ) or not isinstance ( length , int ) or length <= 0 or length > end - address :
raise CapabilityUnavailableError ( "Hopper reported an invalid string object extent at %s " % _hex ( address ))
raw = segment . readBytes ( address , length )
if not isinstance ( raw , ( bytes , bytearray )) or len ( raw ) != length :
raise CapabilityUnavailableError ( "Hopper could not read the complete string object at %s " % _hex ( address ))
kind = segment . getTypeAtAddress ( address )
encodings = ( "utf-8" , "latin-1" ) if kind == segment . TYPE_ASCII else ( "utf-16-le" , "utf-16-be" ) if kind == segment . TYPE_UNICODE else ()
# Native display escaping is not invertible (a literal backslash-n and a
# newline can render alike). Decode byte strings directly; for UTF-16,
# compare rendered candidates to establish an unambiguous byte order.
candidates = []
for encoding in encodings :
terminator = b " \x00\x00 " if encoding . startswith ( "utf-16" ) else b " \x00 "
terminated = raw . endswith ( terminator )
payload = raw [: - len ( terminator )] if terminated else raw
try :
value = payload . decode ( encoding )
except UnicodeDecodeError :
continue
record = { "value" : value , "provider_value" : display , "string" : { "encoding" : encoding , "encoding_status" : "inferred" , "termination" : "present_or_not_required" if terminated else "missing" , "byte_length" : length }}
if kind == segment . TYPE_ASCII :
return record
rendered = value . replace ( '"' , ' \\ "' ) . replace ( " \n " , " \\ n" ) . replace ( " \r " , " \\ r" ) . replace ( " \t " , " \\ t" )
if rendered == display or ( display . endswith ( " \u2026 " ) and display [: - 1 ] and rendered . startswith ( display [: - 1 ])):
candidates . append ( record )
if candidates and len ({ candidate [ "value" ] for candidate in candidates }) == 1 :
return candidates [ 0 ]
raise CapabilityUnavailableError ( "Hopper's string display cannot be reconciled with its typed bytes at %s ; inspect the bytes and encoding explicitly" % _hex ( address ))
2026-10-09 05:05:39 +08:00
def _string_inventory ( document , addresses = None ):
key = ( id ( document ), "string_objects" )
objects = _search_inventory_cache . get ( key )
if objects is None :
objects = { address : ( segment , display ) for segment in document . getSegmentsList () for display , address in segment . getStringsList ()}
_search_inventory_cache [ key ] = objects
2026-10-09 04:07:47 +08:00
key = ( id ( document ), "string_records" )
2026-10-09 05:05:39 +08:00
records = _search_inventory_cache . setdefault ( key , {})
selected = sorted ( objects if addresses is None else objects . keys () & addresses )
for address in selected :
name = _hex ( address )
if name not in records :
segment , display = objects [ address ]
try :
records [ name ] = _string_record ( segment , address , display )
except CapabilityUnavailableError as error :
records [ name ] = { "value" : display , "provider_value" : display , "decoding" : _unavailable ( str ( error ))}
return { _hex ( address ): records [ _hex ( address )] for address in selected }
2026-07-12 03:25:18 +08:00
2026-07-12 21:12:18 +00:00
def _invalidate_search_inventory ( document ):
"""Discard derived names after analysis metadata changes."""
document_id = id ( document )
for key in list ( _search_inventory_cache ):
if key [ 0 ] == document_id :
del _search_inventory_cache [ key ]
2026-07-23 23:18:17 +08:00
def _invalidate_pseudocode ( document ):
"""Discard cached decompilation after an analysis annotation changes."""
document_id = id ( document )
for key in list ( _pseudocode_cache ):
if key [ 0 ] == document_id :
del _pseudocode_cache [ key ]
def _pseudocode ( document , procedure ):
"""Reuse one provider decompilation for repeated bounded projections."""
key = ( id ( document ), procedure . getEntryPoint ())
if key not in _pseudocode_cache :
_pseudocode_cache [ key ] = procedure . decompile ()
return _pseudocode_cache [ key ]
2026-07-12 21:12:18 +00:00
def _search_inventory ( document , kind ):
"""Cache an immutable, address-sorted inventory for an unchanged document."""
key = ( id ( document ), kind )
inventory = _search_inventory_cache . get ( key )
if inventory is None :
2026-07-23 23:18:17 +08:00
if kind == "procedure" :
values = _procedure_map ( document )
elif kind == "string" :
2026-10-18 19:19:20 +08:00
values = _string_inventory ( document )
2026-07-23 23:18:17 +08:00
elif kind == "name" :
values = { _hex ( address ): value for address , value in _name_map ( document ) . items ()}
else :
raise ValueError ( "Unknown inventory kind" )
2026-07-12 21:12:18 +00:00
inventory = tuple ( sorted ( values . items (), key = lambda item : int ( item [ 0 ], 16 )))
_search_inventory_cache [ key ] = inventory
return inventory
2026-09-29 07:08:46 +08:00
def _search_results ( document , kind , params ):
2026-07-12 21:12:18 +00:00
pattern = params . get ( "pattern" )
2026-09-29 07:08:46 +08:00
if not isinstance ( pattern , str ) or not pattern :
2026-10-04 17:25:09 +08:00
raise InvalidRequestError ( "pattern must be a non-empty string" )
2026-07-12 21:12:18 +00:00
mode = params . get ( "mode" , "literal" )
if mode not in ( "literal" , "regex" ):
2026-10-04 17:25:09 +08:00
raise InvalidRequestError ( "mode must be literal or regex" )
2026-07-12 21:12:18 +00:00
case_sensitive = params . get ( "case_sensitive" , False )
if not isinstance ( case_sensitive , bool ):
2026-10-04 17:25:09 +08:00
raise InvalidRequestError ( "case_sensitive must be a boolean" )
2026-07-12 21:12:18 +00:00
if mode == "literal" :
needle = pattern if case_sensitive else pattern . casefold ()
matches = lambda value : needle in ( value if case_sensitive else value . casefold ())
else :
2026-10-09 05:06:48 +08:00
raise CapabilityUnavailableError ( "Regex matching requires REA's supervised worker; the native bridge only executes literal searches" )
2026-07-12 21:12:18 +00:00
2026-10-09 11:39:19 +01:00
if kind == "string" :
2026-10-18 19:19:20 +08:00
return [
{ "address" : address , ** record }
for address , record in _search_inventory ( document , kind )
if matches ( record [ "value" ])
2026-09-29 07:08:46 +08:00
]
2026-10-18 19:19:20 +08:00
return [
{ "address" : address , "value" : value }
for address , value in _search_inventory ( document , kind )
if matches ( value )
]
2026-07-12 18:59:53 +08:00
2026-07-12 19:32:13 +08:00
def _unavailable ( reason ):
"""Describe evidence the public Hopper API cannot truthfully provide."""
return { "available" : False , "reason" : reason }
2026-07-12 20:36:00 +08:00
def _name_map ( document ):
result = {}
for segment in document . getSegmentsList ():
for address in segment . getNamedAddresses ():
name = segment . getNameAtAddress ( address )
if name is not None :
result [ address ] = name
return result
2026-07-23 23:18:17 +08:00
def _render_instruction ( segment , address ):
instruction = segment . getInstructionAtAddress ( address )
if instruction is None :
return None
arguments = [
instruction . getFormattedArgument ( index )
for index in range ( instruction . getArgumentCount ())
]
suffix = ", " . join ( value for value in arguments if value is not None )
return " %s : %s%s " % (
_hex ( address ),
instruction . getInstructionString (),
( " " + suffix ) if suffix else "" ,
)
2026-09-29 11:22:31 +08:00
def _assembly ( procedure ):
"""Render assembly while guarding against malformed instruction cycles."""
2026-07-12 03:25:18 +08:00
segment = procedure . getSegment ()
2026-10-09 05:31:25 +08:00
return " \n " . join (
line for address in _instruction_addresses ( procedure )
if ( line := _render_instruction ( segment , address )) is not None
)
2026-07-12 03:25:18 +08:00
2026-07-23 23:18:17 +08:00
def _read_function_instructions ( document , params ):
2026-09-29 07:08:46 +08:00
"""Read all raw instructions without invoking decompilation."""
2026-07-23 23:18:17 +08:00
procedure = _procedure ( document , params . get ( "procedure" ))
2026-09-29 11:22:31 +08:00
addresses = _instruction_addresses ( procedure )
2026-07-23 23:18:17 +08:00
segment = procedure . getSegment ()
items = []
2026-09-29 07:08:46 +08:00
for address in addresses :
2026-07-23 23:18:17 +08:00
line = _render_instruction ( segment , address )
if line is not None :
items . append ( line )
return {
"procedure" : _procedure_identity ( procedure ),
2026-09-29 07:08:46 +08:00
"instructions" : items ,
2026-07-23 23:18:17 +08:00
"limitations" : [
"Instruction text and ordering are Hopper-specific representations." ,
"The fast path does not decompile the procedure or scan whole-program names and strings." ,
],
}
2026-07-12 18:59:53 +08:00
def _analyze_function ( document , params ):
2026-09-29 07:08:46 +08:00
"""Collect the complete function dossier for agent callers."""
2026-07-12 18:59:53 +08:00
procedure = _procedure ( document , params . get ( "procedure" ))
2026-09-29 11:22:31 +08:00
addresses = _instruction_addresses ( procedure )
2026-07-12 18:59:53 +08:00
blocks = []
all_blocks = list ( procedure . basicBlockIterator ())
2026-07-12 20:36:00 +08:00
for block in all_blocks :
successors = []
for index in range ( block . getSuccessorCount ()):
successor = block . getSuccessorAddressAtIndex ( index )
if successor not in BAD_ADDRESSES :
successors . append ( _hex ( successor ))
2026-07-12 19:32:13 +08:00
blocks . append ({
"start" : _hex ( block . getStartingAddress ()),
2026-10-09 05:31:25 +08:00
"end" : _hex ( _basic_block_end ( procedure , block )),
2026-07-12 20:36:00 +08:00
"successors" : sorted ( set ( successors ), key = lambda value : int ( value , 16 )),
2026-07-12 19:32:13 +08:00
})
2026-10-14 07:11:55 +08:00
pseudo = _pseudocode ( document , procedure )
2026-09-29 07:08:46 +08:00
assembly_lines = _assembly ( procedure ) . splitlines ()
2026-07-12 20:36:00 +08:00
callers = sorted (( _procedure_identity ( item ) for item in procedure . getAllCallerProcedures ()), key = lambda item : int ( item [ "address" ], 16 ))
callees = sorted (( _procedure_identity ( item ) for item in procedure . getAllCalleeProcedures ()), key = lambda item : int ( item [ "address" ], 16 ))
2026-07-12 18:59:53 +08:00
comments = []
2026-07-12 20:36:00 +08:00
edges = set ()
for address in addresses :
segment = _segment ( document , address )
comment = segment . getCommentAtAddress ( address )
inline_comment = segment . getInlineCommentAtAddress ( address )
if comment :
comments . append ({ "address" : _hex ( address ), "kind" : "comment" , "text" : comment })
if inline_comment :
comments . append ({ "address" : _hex ( address ), "kind" : "inline" , "text" : inline_comment })
for target in segment . getReferencesFromAddress ( address ):
edges . add (( address , target ))
for source in segment . getReferencesOfAddress ( address ):
edges . add (( source , address ))
2026-07-12 18:59:53 +08:00
incoming = []
2026-07-12 20:36:00 +08:00
outgoing = []
procedure_addresses = set ( addresses )
2026-10-09 05:05:39 +08:00
outgoing_calls , unresolved_calls = _native_calls ( procedure , "outgoing" )
incoming_calls , _ = _native_calls ( procedure , "incoming" )
calls = { ** incoming_calls , ** outgoing_calls }
edges . update ( calls )
2026-07-12 20:36:00 +08:00
for source , target in sorted ( edges ):
source_procedure , _ = _containing_procedure ( document , source )
target_procedure , _ = _containing_procedure ( document , target )
item = {
"source_address" : _hex ( source ),
"target_address" : _hex ( target ),
"source_procedure" : _procedure_identity ( source_procedure ) if source_procedure is not None else None ,
"target_procedure" : _procedure_identity ( target_procedure ) if target_procedure is not None else None ,
2026-10-09 05:05:39 +08:00
"kind" : _unavailable ( "Hopper exposes partial CallReference classification, but not detailed reference flags" ),
** ({ "call" : calls [( source , target )]} if ( source , target ) in calls else {}),
2026-07-12 20:36:00 +08:00
}
if target in procedure_addresses and source not in procedure_addresses :
incoming . append ( item )
if source in procedure_addresses :
outgoing . append ( item )
2026-10-09 05:05:39 +08:00
string_map = { int ( address , 16 ): record for address , record in _string_inventory ( document , { int ( edge [ "target_address" ], 16 ) for edge in outgoing }) . items ()}
2026-07-23 23:18:17 +08:00
name_map = {
int ( address , 16 ): value
for address , value in _search_inventory ( document , "name" )
}
2026-07-12 20:36:00 +08:00
referenced_strings = []
referenced_names = []
for edge in outgoing :
target = int ( edge [ "target_address" ], 16 )
if target in string_map :
2026-10-09 05:05:39 +08:00
referenced_strings . append ({ "address" : edge [ "target_address" ], ** string_map [ target ], "source_address" : edge [ "source_address" ]})
2026-07-12 20:36:00 +08:00
if target in name_map :
referenced_names . append ({ "address" : edge [ "target_address" ], "value" : name_map [ target ], "source_address" : edge [ "source_address" ]})
comments . sort ( key = lambda item : ( int ( item [ "address" ], 16 ), item [ "kind" ]))
referenced_strings . sort ( key = lambda item : ( int ( item [ "address" ], 16 ), int ( item [ "source_address" ], 16 )))
referenced_names . sort ( key = lambda item : ( int ( item [ "address" ], 16 ), int ( item [ "source_address" ], 16 )))
2026-07-12 18:59:53 +08:00
return {
2026-10-05 21:15:04 +08:00
"procedure" : {
** _procedure_identity ( procedure ),
"signature" : procedure . signatureString (),
"locals" : _procedure_locals ( procedure ),
},
2026-10-14 07:11:55 +08:00
"pseudocode" : pseudo or "" ,
2026-09-29 07:08:46 +08:00
"assembly" : assembly_lines ,
"comments" : comments ,
"callers" : callers , "callees" : callees ,
"incoming_references" : incoming ,
"outgoing_references" : outgoing ,
2026-10-09 05:05:39 +08:00
"unresolved_calls" : unresolved_calls ,
2026-09-29 07:08:46 +08:00
"referenced_strings" : referenced_strings ,
"referenced_names" : referenced_names ,
"basic_blocks" : blocks ,
2026-10-10 03:11:08 +08:00
"native_api" : None ,
"native_value_flow" : None ,
2026-10-14 07:11:55 +08:00
"facet_availability" : {
"pseudocode" : { "available" : True } if pseudo is not None else _unavailable ( "Hopper did not supply pseudocode." ),
** { facet : { "available" : True } for facet in (
"assembly" , "comments" , "callers" , "callees" , "references" , "strings_names" , "cfg"
)},
},
2026-07-16 03:09:34 +08:00
"limitations" : [
2026-10-09 05:05:39 +08:00
"Native CallReference classifications are observed where available; detailed reference flags remain unknown." ,
2026-07-16 03:09:34 +08:00
"Hopper's public Python API does not expose equivalent external or thunk classification in this dossier." ,
2026-10-09 05:05:39 +08:00
"Native calls without resolved target addresses are not represented as edges; enumeration beyond reported CallReference objects is unknown." ,
2026-07-16 03:09:34 +08:00
"Pseudocode and assembly are provider-specific representations, not original source." ,
],
2026-07-12 18:59:53 +08:00
}
2026-07-12 03:25:18 +08:00
def _dispatch ( method , params ):
2026-07-12 08:45:35 +08:00
"""Dispatch only the closed operation set implemented by REA's public tools."""
2026-07-12 03:25:18 +08:00
if method == "health" :
2026-10-14 08:28:14 +08:00
return {
"name" : "REA Hopper bridge" ,
"version" : "1.0.0" ,
"run_id" : REA_RUN_ID ,
"process_id" : os . getpid (),
}
2026-07-14 06:05:25 +08:00
if method in ( "shutdown" , "shutdown_document" ):
2026-07-14 04:18:26 +08:00
document = _session_document ()
if document is None :
return { "shutdown" : True , "analysis_stopped" : True , "document_closed" : True }
2026-09-29 07:08:46 +08:00
if method == "shutdown" and not REA_OWNS_PROCESS_LIFETIME :
return {
"shutdown" : True ,
"analysis_stopped" : not document . backgroundProcessActive (),
"document_closed" : False ,
"document_retained" : True ,
}
2026-07-14 04:18:26 +08:00
if document . backgroundProcessActive ():
document . requestBackgroundProcessStop ()
2026-07-14 06:05:25 +08:00
if method == "shutdown" and REA_OWNS_PROCESS_LIFETIME :
return {
"shutdown" : True ,
"analysis_stopped" : not document . backgroundProcessActive (),
"document_closed" : False ,
"cleanup_required" : True ,
}
2026-07-14 04:18:26 +08:00
if document . backgroundProcessActive ():
document . waitForBackgroundProcessToEnd ()
document . closeDocument ()
2026-07-14 06:05:25 +08:00
analysis_stopped = not document . backgroundProcessActive ()
2026-07-14 04:18:26 +08:00
document_closed = _session_document () is None
return {
"shutdown" : True ,
"analysis_stopped" : analysis_stopped ,
"document_closed" : document_closed ,
}
2026-07-12 03:25:18 +08:00
if method == "list_documents" :
2026-10-09 02:04:29 +08:00
return [ document . getDocumentName () for document in _api () . documents ()]
2026-07-12 03:25:18 +08:00
if method == "current_document" :
return _document () . getDocumentName ()
document = _document ( params . get ( "document" ))
2026-07-24 13:14:01 +00:00
if method in EXHAUSTIVE_ANALYSIS_METHODS :
_api () . require_analysis_complete ( document , method )
2026-07-12 03:25:18 +08:00
2026-07-12 18:59:53 +08:00
if method == "analyze_function" :
return _analyze_function ( document , params )
2026-07-23 23:18:17 +08:00
if method == "read_function_instructions" :
return _read_function_instructions ( document , params )
2026-07-24 13:14:01 +00:00
if method == "read_bytes" :
length = params . get ( "length" , 256 )
if isinstance ( length , bool ) or not isinstance ( length , int ):
2026-10-04 17:25:09 +08:00
raise InvalidRequestError ( "Byte-read length must be an integer" )
2026-09-29 10:53:45 +08:00
if length < 1 :
2026-10-04 17:25:09 +08:00
raise InvalidRequestError ( "Byte-read length must be at least 1" )
2026-07-24 13:14:01 +00:00
reader = getattr ( document , "readBytes" , None )
if not callable ( reader ):
raise CapabilityUnavailableError (
"Hopper's public Python API does not expose readBytes"
)
address = _address ( document , params . get ( "address" ))
2026-10-09 02:04:29 +08:00
segment = _segment ( document , address )
# Document.readBytes delegates to one Segment and returns False when
# the requested range crosses its end. Preserve the readable prefix.
available = segment . getStartingAddress () + segment . getLength () - address
value = reader ( address , min ( length , available ))
if value is False or value is None :
raise InvalidRequestError ( "Hopper could not read the requested byte range at %s " % _hex ( address ))
2026-07-24 13:14:01 +00:00
if not isinstance ( value , ( bytes , bytearray )):
raise CapabilityUnavailableError (
2026-10-09 02:04:29 +08:00
"Hopper readBytes returned %s for the requested %s -byte range at %s "
% ( type ( value ) . __name__ , length , _hex ( address ))
2026-07-24 13:14:01 +00:00
)
data = bytes ( value )
return {
"address" : _hex ( address ),
"requested_bytes" : length ,
"returned_bytes" : len ( data ),
"bytes_hex" : data . hex (),
"complete" : len ( data ) == length ,
}
if method == "address_to_file_offset" :
mapper = getattr ( document , "getFileOffsetFromAddress" , None )
if not callable ( mapper ):
raise CapabilityUnavailableError (
"Hopper's public Python API does not expose getFileOffsetFromAddress"
)
address = _address ( document , params . get ( "address" ))
offset = mapper ( address )
if (
isinstance ( offset , bool )
or not isinstance ( offset , int )
or offset in BAD_ADDRESSES
or offset < 0
):
2026-10-04 17:25:09 +08:00
raise InvalidRequestError ( "Address has no authoritative file-offset mapping" )
2026-10-09 02:04:29 +08:00
inverse = getattr ( document , "getAddressFromFileOffset" , None )
if not callable ( inverse ):
raise CapabilityUnavailableError (
"Hopper's public Python API cannot verify the file-offset mapping by reverse lookup"
)
# Synthetic memory can report offset zero, which belongs to the actual
# file header. An integer alone does not prove source-file provenance.
if inverse ( offset ) != address :
raise InvalidRequestError ( "Address has no authoritative file-offset mapping: reverse lookup disagrees at %s " % _hex ( address ))
2026-10-09 06:12:55 +08:00
if offset > 9007199254740991 :
raise CapabilityUnavailableError ( "Provider file offset exceeds the exact JSON integer range" )
header_address = inverse ( 0 )
reader = getattr ( document , "readBytes" , None )
header = reader ( header_address , 12 ) if header_address not in BAD_ADDRESSES and document . getSegmentAtAddress ( header_address ) is not None and callable ( reader ) else None
return {
"address" : _hex ( address ),
"provider_file_offset" : offset ,
"provider_source_path" : document . getExecutableFilePath (),
"provider_image_header_hex" : bytes ( header ) . hex () if isinstance ( header , ( bytes , bytearray )) and len ( header ) == 12 else None ,
}
2026-07-12 19:32:13 +08:00
if method == "resolve_containing_procedure" :
address = _address ( document , params . get ( "address" ))
procedure , reason = _containing_procedure ( document , address )
if procedure is None :
return { "query_address" : _hex ( address ), "found" : False , "procedure" : None , "reason" : reason }
return { "query_address" : _hex ( address ), "found" : True , "procedure" : _procedure_identity ( procedure )}
if method == "procedure_references" :
return _procedure_references ( document , params )
2026-07-12 18:59:53 +08:00
2026-07-12 03:25:18 +08:00
if method == "current_address" :
return _hex ( document . getCurrentAddress ())
if method == "current_procedure" :
return _procedure_name ( _procedure ( document ))
if method == "goto_address" :
2026-07-12 06:53:31 +08:00
address = _address ( document , params . get ( "address" ))
2026-10-09 02:42:25 +08:00
segment = _segment ( document , address )
expected = segment . getInstructionStart ( address )
2026-07-12 03:25:18 +08:00
document . moveCursorAtAddress ( address )
2026-10-09 02:42:25 +08:00
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 )
2026-07-12 03:25:18 +08:00
if method in ( "address_name" , "comment" , "inline_comment" , "xrefs" ):
target = _address ( document , params . get ( "address" ))
segment = _segment ( document , target )
if method == "address_name" :
return segment . getNameAtAddress ( target )
if method == "comment" :
return segment . getCommentAtAddress ( target )
if method == "inline_comment" :
return segment . getInlineCommentAtAddress ( target )
return [ _hex ( value ) for value in segment . getReferencesOfAddress ( target )]
if method in ( "next_address" , "prev_address" ):
target = _address ( document , params . get ( "address" ))
2026-10-09 02:42:25 +08:00
segment = _segment ( document , target )
2026-07-12 03:25:18 +08:00
if method == "next_address" :
2026-10-09 02:42:25 +08:00
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
2026-07-12 03:25:18 +08:00
else :
2026-10-09 02:42:25 +08:00
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" )
2026-07-12 03:25:18 +08:00
return _hex ( result )
if method == "list_segments" :
result = []
2026-07-12 21:12:18 +00:00
permission_limitation = _unavailable (
"Hopper's public Python API does not expose segment or section permissions"
)
2026-07-12 03:25:18 +08:00
for segment in document . getSegmentsList ():
start = segment . getStartingAddress ()
2026-07-12 21:12:18 +00:00
sections = [{
"name" : section . getName (),
"start" : _hex ( section . getStartingAddress ()),
"end" : _hex ( section . getStartingAddress () + section . getLength ()),
"readable" : None ,
"writable" : None ,
"executable" : None ,
"permissions" : permission_limitation ,
"provenance" : "hopper-public-python-api" ,
} for section in segment . getSectionsList ()]
2026-07-12 19:32:13 +08:00
result . append ({
"name" : segment . getName (),
"start" : _hex ( start ),
"end" : _hex ( start + segment . getLength ()),
2026-07-12 21:12:18 +00:00
"readable" : None ,
2026-07-12 19:32:13 +08:00
"writable" : None ,
"executable" : None ,
2026-07-12 21:12:18 +00:00
"permissions" : permission_limitation ,
"provenance" : "hopper-public-python-api" ,
2026-07-12 19:32:13 +08:00
"sections" : sections ,
})
2026-07-12 03:25:18 +08:00
return result
if method == "list_procedures" :
2026-09-29 07:08:46 +08:00
return [{ "address" : address , "value" : value } for address , value in _search_inventory ( document , "procedure" )]
2026-07-12 03:25:18 +08:00
if method == "list_strings" :
requested = params . get ( "address" )
2026-10-09 05:05:39 +08:00
values = _string_inventory ( document , None if requested is None else { _address ( document , requested )})
2026-10-09 04:07:47 +08:00
return [{ "address" : address , ** record } for address , record in values . items ()]
2026-07-12 03:25:18 +08:00
if method == "list_names" :
2026-07-23 23:18:17 +08:00
result = dict ( _search_inventory ( document , "name" ))
2026-07-12 03:25:18 +08:00
requested = params . get ( "address" )
2026-07-12 18:59:53 +08:00
if requested is not None :
key = _hex ( _address ( document , requested ))
result = { key : result [ key ]} if key in result else {}
2026-10-04 17:25:09 +08:00
return [{ "address" : address , "value" : value } for address , value in result . items ()]
2026-07-12 03:25:18 +08:00
if method in ( "search_procedures" , "search_strings" ):
2026-07-12 21:12:18 +00:00
kind = "procedure" if method == "search_procedures" else "string"
2026-09-29 07:08:46 +08:00
return _search_results ( document , kind , params )
2026-07-12 03:25:18 +08:00
if method . startswith ( "procedure_" ):
procedure = _procedure ( document , params . get ( "procedure" ))
if method == "procedure_address" :
return _hex ( procedure . getEntryPoint ())
if method == "procedure_assembly" :
return _assembly ( procedure )
if method == "procedure_pseudo_code" :
2026-07-23 23:18:17 +08:00
return _pseudocode ( document , procedure )
2026-07-12 03:25:18 +08:00
if method == "procedure_callers" :
2026-07-14 04:21:28 +08:00
return sorted (( _hex ( item . getEntryPoint ()) for item in procedure . getAllCallerProcedures ()), key = lambda value : int ( value , 16 ))
2026-07-12 03:25:18 +08:00
if method == "procedure_callees" :
2026-07-14 04:21:28 +08:00
return sorted (( _hex ( item . getEntryPoint ()) for item in procedure . getAllCalleeProcedures ()), key = lambda value : int ( value , 16 ))
2026-07-12 03:25:18 +08:00
if method == "procedure_info" :
blocks = list ( procedure . basicBlockIterator ())
2026-10-09 05:31:25 +08:00
length = sum ( max ( 0 , _basic_block_end ( procedure , block ) - block . getStartingAddress ()) for block in blocks )
2026-10-05 21:15:04 +08:00
return {
"name" : _procedure_name ( procedure ),
"entrypoint" : _hex ( procedure . getEntryPoint ()),
"basicblock_count" : procedure . getBasicBlockCount (),
"length" : length ,
"signature" : procedure . signatureString (),
"locals" : _procedure_locals ( procedure ),
"classification" : None ,
"body" : _procedure_identity ( procedure )[ "body" ],
}
2026-07-12 03:25:18 +08:00
if method == "set_address_name" :
2026-07-12 06:53:31 +08:00
address = _address ( document , params . get ( "address" ))
2026-10-09 03:38:55 +08:00
name = _symbol_name ( params [ "name" ], "Name" )
2026-10-09 02:42:25 +08:00
_validate_name_targets ( document , [( params . get ( "address" ), address , name )])
2026-07-23 23:18:17 +08:00
try :
2026-10-09 02:42:25 +08:00
result = document . setNameAtAddress ( address , name )
2026-07-23 23:18:17 +08:00
finally :
_invalidate_search_inventory ( document )
_invalidate_pseudocode ( document )
2026-10-09 03:38:55 +08:00
return bool ( result ) and _name_matches ( document , address , name )
2026-07-12 03:25:18 +08:00
if method == "set_addresses_names" :
2026-10-09 02:04:29 +08:00
# Validate all destinations before applying any annotation. A malformed
# later address must not discard the result of an earlier mutation.
2026-10-09 03:38:55 +08:00
names = [( key , _address ( document , key ), _symbol_name ( value , "Name for %s " % key )) for key , value in params [ "names" ] . items ()]
2026-10-09 02:42:25 +08:00
_validate_name_targets ( document , names )
2026-07-23 23:18:17 +08:00
try :
2026-10-09 02:04:29 +08:00
result = { key : document . setNameAtAddress ( address , value ) for key , address , value in names }
2026-07-23 23:18:17 +08:00
finally :
_invalidate_search_inventory ( document )
_invalidate_pseudocode ( document )
2026-10-09 03:38:55 +08:00
return { key : bool ( result [ key ]) and _name_matches ( document , address , value ) for key , address , value in names }
2026-07-12 03:25:18 +08:00
if method in ( "set_comment" , "set_inline_comment" ):
2026-07-12 06:53:31 +08:00
address = _address ( document , params . get ( "address" ))
2026-10-09 02:42:25 +08:00
comment = _api_text ( params [ "comment" ], "Comment" )
2026-07-12 03:25:18 +08:00
segment = _segment ( document , address )
setter = segment . setCommentAtAddress if method == "set_comment" else segment . setInlineCommentAtAddress
getter = segment . getCommentAtAddress if method == "set_comment" else segment . getInlineCommentAtAddress
2026-10-09 02:42:25 +08:00
setter ( address , comment )
2026-07-23 23:18:17 +08:00
_invalidate_pseudocode ( document )
2026-10-09 02:04:29 +08:00
observed = getter ( address )
2026-10-09 02:42:25 +08:00
return observed == comment or ( comment == "" and observed is None )
2026-07-12 03:25:18 +08:00
if method == "list_bookmarks" :
2026-10-20 22:39:23 +08:00
return [{ "address" : _hex ( item ), "name" : _bookmark_name ( document , item )} for item in document . getBookmarks ()]
2026-07-12 03:25:18 +08:00
if method == "set_bookmark" :
2026-07-12 06:53:31 +08:00
address = _address ( document , params . get ( "address" ))
2026-10-09 03:38:55 +08:00
_segment ( document , address )
2026-10-09 02:42:25 +08:00
name = params . get ( "name" )
if name is not None :
_api_text ( name , "Bookmark name" )
2026-10-20 22:39:23 +08:00
has_bookmark = getattr ( document , "hasBookmarkAtAddress" , None )
if not callable ( has_bookmark ):
raise CapabilityUnavailableError (
"Hopper's public Python API does not expose hasBookmarkAtAddress"
)
exists = has_bookmark ( address )
if name is None and exists :
# Omission means preserve the existing bookmark and its name.
return True
if not callable ( getattr ( document , "getBookmarkName" , None )):
raise CapabilityUnavailableError (
"Hopper's public Python API does not expose getBookmarkName"
)
if name == "" and exists :
rename = getattr ( document , "renameBookmarkAtAddress" , None )
if not callable ( rename ):
raise CapabilityUnavailableError (
"Hopper's public Python API does not expose renameBookmarkAtAddress"
)
rename ( address , name )
else :
setter = getattr ( document , "setBookmarkAtAddress" , None )
if not callable ( setter ):
raise CapabilityUnavailableError (
"Hopper's public Python API does not expose setBookmarkAtAddress"
)
setter ( address , name )
if not has_bookmark ( address ):
return False
observed = _bookmark_name ( document , address )
if name is None :
return observed is None or observed == ""
return observed == name or ( name == "" and observed is None )
2026-07-12 03:25:18 +08:00
if method == "unset_bookmark" :
2026-07-12 06:53:31 +08:00
address = _address ( document , params . get ( "address" ))
2026-10-09 03:38:55 +08:00
# Permit removing a legacy orphan bookmark, but do not report success
# for an unmapped address with no bookmark to remove.
if not document . hasBookmarkAtAddress ( address ):
_segment ( document , address )
2026-07-12 03:25:18 +08:00
document . removeBookmarkAtAddress ( address )
return not document . hasBookmarkAtAddress ( address )
2026-10-04 17:25:09 +08:00
raise InvalidRequestError ( "Unknown bridge method" )
2026-07-12 03:25:18 +08:00
def _serve_connection ( connection ):
2026-09-29 16:28:46 +08:00
"""Serve one capability-authenticated NDJSON connection."""
2026-07-12 03:25:18 +08:00
file = connection . makefile ( "rwb" )
2026-10-11 22:35:01 +08:00
should_stop = False
try :
while True :
line = file . readline ()
if not line :
break
request_id = None
authenticated = False
should_stop = False
2026-10-04 17:25:09 +08:00
try :
2026-10-11 22:35:01 +08:00
try :
request = json . loads ( line . decode ( "utf-8" ))
except ( UnicodeDecodeError , json . JSONDecodeError ) as error :
raise InvalidRequestError ( "Invalid bridge request JSON" ) from error
if not isinstance ( request , dict ) or set ( request ) != { "id" , "token" , "method" , "params" }:
raise InvalidRequestError ( "Invalid bridge request shape" )
request_id = request [ "id" ]
if isinstance ( request_id , bool ) or not isinstance ( request_id , int ) or request_id < 0 :
raise InvalidRequestError ( "Invalid bridge request id" )
if (
not isinstance ( request [ "method" ], str ) or not request [ "method" ]
or not isinstance ( request [ "params" ], dict )
):
raise InvalidRequestError ( "Invalid bridge method or parameters" )
if (
not isinstance ( request [ "token" ], str ) or not request [ "token" ] . isascii ()
or not hmac . compare_digest ( request [ "token" ], REA_TOKEN )
):
raise PermissionError ( "Invalid bridge capability" )
authenticated = True
2026-10-16 05:09:35 +08:00
# The connection grace period only bounds unauthenticated idle
# clients. Native analysis and a connected owner's idle time
# remain unbounded after capability validation.
connection . settimeout ( None )
2026-07-25 14:29:02 +00:00
_write_message ( file , { "id" : request_id , "event" : {
2026-10-11 22:35:01 +08:00
"type" : "progress" ,
"phase" : "hopper_bridge" ,
"completed" : 0 ,
"total" : 1 ,
"message" : "Hopper bridge started request" ,
2026-07-25 14:29:02 +00:00
}})
2026-10-11 22:35:01 +08:00
result = _dispatch ( request [ "method" ], request [ "params" ])
should_stop = request [ "method" ] in ( "shutdown" , "shutdown_document" ) and (
result . get ( "shutdown" ) is True and (
( result . get ( "document_closed" ) is True and result . get ( "analysis_stopped" ) is True )
or ( result . get ( "document_closed" ) is False and result . get ( "document_retained" ) is True )
)
)
safe_result = _json_safe ( result )
_write_message ( file , { "id" : request_id , "event" : {
"type" : "progress" ,
"phase" : "hopper_bridge" ,
"completed" : 1 ,
"total" : 1 ,
"message" : "Hopper bridge completed request" ,
"terminal" : True ,
}})
response = { "id" : request_id , "result" : safe_result }
except Exception as error :
diagnostic = _safe_diagnostic ( error )
if authenticated :
_write_message ( file , { "id" : request_id , "event" : {
"type" : "diagnostic" ,
"error" : diagnostic ,
}})
response_id = request_id if (
isinstance ( request_id , int )
and not isinstance ( request_id , bool )
and request_id >= 0
) else 0
response = { "id" : response_id , "error" : diagnostic }
_write_message ( file , response )
if should_stop :
break
except ( ConnectionError , OSError ):
# A lost client cannot confirm document closure or revoke this run.
pass
finally :
try :
file . close ()
except OSError :
# Closing a failed writer can repeat the same disconnected-pipe error.
pass
connection . close ()
return should_stop
2026-07-12 03:25:18 +08:00
2026-07-25 14:29:02 +00:00
def _write_message ( file , message ):
"""Write and flush one compact bridge message."""
file . write (( json . dumps ( message , separators = ( "," , ":" )) + " \n " ) . encode ( "utf-8" ))
file . flush ()
2026-07-13 03:14:15 +00:00
def _diagnostic_type ( error ):
2026-07-24 13:14:01 +00:00
if isinstance ( error , CapabilityUnavailableError ):
return "capability_unavailable"
2026-07-13 03:14:15 +00:00
if isinstance ( error , PermissionError ):
return "authorization"
2026-10-04 17:25:09 +08:00
if isinstance ( error , InvalidRequestError ):
2026-07-13 03:14:15 +00:00
return "invalid_request"
return "bridge_exception"
2026-07-25 14:29:02 +00:00
def _safe_diagnostic ( error ):
2026-10-09 02:04:29 +08:00
"""Preserve bridge validation reasons while excluding transport credentials."""
2026-07-25 14:29:02 +00:00
diagnostic_type = _diagnostic_type ( error )
if diagnostic_type == "capability_unavailable" :
2026-09-29 17:00:06 +08:00
message = str ( error )
2026-07-25 14:29:02 +00:00
elif diagnostic_type == "authorization" :
message = "Invalid bridge capability"
elif diagnostic_type == "invalid_request" :
2026-10-09 02:04:29 +08:00
message = str ( error )
2026-07-25 14:29:02 +00:00
else :
2026-09-29 17:00:06 +08:00
message = " %s : Hopper bridge operation failed" % type ( error ) . __name__
2026-07-25 14:29:02 +00:00
return { "code" : - 32000 , "message" : message , "type" : diagnostic_type }
2026-07-12 03:25:18 +08:00
def _run ():
2026-07-12 08:45:35 +08:00
"""Own a permission-restricted, single-client Unix socket for this bridge."""
2026-07-12 07:44:30 +08:00
if os . path . exists ( REA_SOCKET ):
os . unlink ( REA_SOCKET )
2026-07-12 03:25:18 +08:00
server = socket . socket ( socket . AF_UNIX , socket . SOCK_STREAM )
2026-07-12 07:44:30 +08:00
server . bind ( REA_SOCKET )
os . chmod ( REA_SOCKET , 0o600 )
2026-07-12 03:25:18 +08:00
server . listen ( 1 )
2026-10-16 05:09:35 +08:00
# A bridge can start after its caller's startup/cleanup deadline. Bound
# the first accept as well as reconnects so an orphan cannot pin Hopper's
# dedicated Python thread and prevent later documents from starting REA.
server . settimeout ( REA_RECONNECT_TIMEOUT_SECONDS )
2026-07-12 03:25:18 +08:00
try :
2026-10-11 22:35:01 +08:00
while True :
try :
connection , _ = server . accept ()
except socket . timeout :
break
2026-10-16 05:09:35 +08:00
connection . settimeout ( REA_RECONNECT_TIMEOUT_SECONDS )
2026-10-11 22:35:01 +08:00
if _serve_connection ( connection ):
break
# Match REA's cleanup handshake budget without pinning Hopper's
# Python thread indefinitely after a client disappears.
server . settimeout ( REA_RECONNECT_TIMEOUT_SECONDS )
2026-07-12 03:25:18 +08:00
finally :
server . close ()
2026-07-12 07:44:30 +08:00
if os . path . exists ( REA_SOCKET ):
os . unlink ( REA_SOCKET )
2026-07-12 03:25:18 +08:00
# Hopper's public objects are bound to its dedicated Python execution thread.
2026-09-29 07:08:46 +08:00
# Keep dispatch on that thread; moving calls to a worker can deadlock.
2026-07-24 13:14:01 +00:00
if __name__ == "__main__" :
2026-09-29 07:08:46 +08:00
_bind_session_document ()
2026-07-24 13:14:01 +00:00
_run ()