Files

1477 lines
52 KiB
Python

import os
import time
import string
import logging
import threading
import collections
from lighthouse.util.misc import *
from lighthouse.util.debug import *
from lighthouse.util.python import *
from lighthouse.util.qt import await_future, await_lock, flush_qt_events
from lighthouse.util.disassembler import disassembler
from lighthouse.ui import ModuleSelector
from lighthouse.reader import CoverageReader
from lighthouse.metadata import DatabaseMetadata, metadata_progress
from lighthouse.coverage import DatabaseCoverage
from lighthouse.exceptions import *
from lighthouse.composer.parser import *
logger = logging.getLogger("Lighthouse.Director")
#------------------------------------------------------------------------------
# Constants Definitions
#------------------------------------------------------------------------------
HOT_SHELL = "Hot Shell"
NEW_COMPOSITION = "New Composition"
AGGREGATE = "Aggregate"
SPECIAL_NAMES = set([HOT_SHELL, AGGREGATE, NEW_COMPOSITION])
AGGREGATE_ALIAS = '*'
ASCII_SHORTHAND = list(string.ascii_uppercase)
SHORTHAND_ALIASES = set([AGGREGATE_ALIAS]) | set(ASCII_SHORTHAND)
RESERVED_NAMES = SHORTHAND_ALIASES | SPECIAL_NAMES
#------------------------------------------------------------------------------
# The Coverage Director
#------------------------------------------------------------------------------
class CoverageDirector(object):
"""
The CoverageDirector manages loaded coverage, and coverage composition.
This class is the 'brain' of Lighthouse. Its primary role is to centralize
loaded coverage and switch between which set is 'active'. It also houses
the logic to perform set operations between loaded coverage.
This provides a platform for researchers to explore the relationship
between any number of coverage files.
"""
def __init__(self, metadata, palette):
# the database metadata cache
self.metadata = metadata
# the plugin color palette
self.palette = palette
#----------------------------------------------------------------------
# Coverage
#----------------------------------------------------------------------
# the coverage file parser
self.reader = CoverageReader()
self._target_whitelist = []
self.suppressed_errors = set()
# the name of the active coverage
self.coverage_name = NEW_COMPOSITION
# a map of loaded or composed database coverages
self._database_coverage = collections.OrderedDict()
#
# the owners map is used in block/coverage blame operations. it
# contains the mapping of node_address --> [ coverage filepaths ]
#
# given any node (basic block) address, we can use this mapping to do
# a reverse lookup to find which loaded coverage sets hit the block.
#
self.owners = collections.defaultdict(set)
#
# the director automatically maintains / generates a few coverage sets
# of its own. these are not directly modifiable by the user, but may
# be influenced by user actions (say, loading new coverage data)
#
# Note that the ordering of the dict below is the order that its items
# will be shown in lists such as the CoverageComboBox dropwdown, etc.
#
self._special_coverage = collections.OrderedDict(
[
(HOT_SHELL, DatabaseCoverage(palette, HOT_SHELL)),
(NEW_COMPOSITION, DatabaseCoverage(palette, NEW_COMPOSITION)),
(AGGREGATE, DatabaseCoverage(palette, AGGREGATE)),
])
# a flag to suspend/resume the automatic coverage aggregation
self._aggregation_suspended = False
#----------------------------------------------------------------------
# Aliases
#----------------------------------------------------------------------
#
# Within the director, one is allowed to alias the names of the loaded
# coverage data that it maintains. right now this is only used to
# assign shorthand names to coverage data.
#
# mapping of {alias: coverage_name}
# eg: 'A' --> 'my_loaded_coverage.log'
#
self._alias2name = {}
#
# mapping of {coverage_name: set(aliases)}
# eg: 'my_loaded_coverage.log' --> set(['A', 'log1', 'foo'])
#
self._name2alias = collections.defaultdict(set)
#
# shorthand 'symbols' are aliases that the director automatically
# assigns to loaded database coverage mappings. these special aliases
# consist of a single capital letter, eg 'A'
#
# these auto-aliased shorthand symbols were intended to be a less
# cumbersome way to reference specific coverage sets while composing.
#
# Example -
#
# given these shorthand aliases:
#
# 'A' --> 'drcov.boombox.exe.04936.0000.proc.log'
# 'B' --> 'drcov.boombox.exe.03297.0000.proc.log'
# 'C' --> 'drcov.boombox.exe.08438.0000.proc.log'
# 'D' --> 'drcov.boombox.exe.02349.0000.proc.log'
# ...
# 'Z' --> 'drcov.boombox.exe.50946.0000.proc.log'
# <eof>
#
# one can more naturally compose interesting coverage equations
#
# ((A & B) | (D & (E - F))) | Z
#
# the existing limitation of shorthand symbols is that there is
# only 26 (A-Z) aliases that can be assigned to coverage sets. There
# is no immediate plans to further expand this range.
#
# the primary justification for this limitation is that I don't
# expect users to be building complex compositions with 26+ coverage
# sets loaded at once. At that point, shorthand aliases really
# aren't going to make things any less cumbersome.
#
self._shorthand = collections.deque(ASCII_SHORTHAND)
#
# assign default aliases
#
# alias the aggregate set to '*'
self._alias_coverage(AGGREGATE, AGGREGATE_ALIAS)
#----------------------------------------------------------------------
# Async Composition Computation
#----------------------------------------------------------------------
#
# the director is responsible for computing the logical/arithmetic
# results of coverage set operations (composing). thanks to our lifted
# metadata, we can do these set computations completely asynchronously.
#
# we use locks, queues, and a background 'composition worker' thread
# to handle these computation requests.
#
self._ast_queue = queue.Queue()
self._composition_lock = threading.Lock()
self._composition_cache = CompositionCache()
self._composition_worker = threading.Thread(
target=self._async_evaluate_ast,
name="EvaluateAST"
)
#----------------------------------------------------------------------
# Callbacks
#----------------------------------------------------------------------
#
# as the director is the data source for much of Lighthouse, it is
# important that anything built on top of it can act on key events or
# changes to the underlying data they consume.
#
# callbacks provide a way for us to notify any interested parties of
# these key events. Below are lists of registered notification
# callbacks. see 'Callbacks' section below for more info.
#
# coverage callbacks
self._coverage_switched_callbacks = []
self._coverage_modified_callbacks = []
self._coverage_created_callbacks = []
self._coverage_deleted_callbacks = []
# director callbacks
self._refreshed_callbacks = []
#--------------------------------------------------------------------------
# Subsystem Lifetime
#--------------------------------------------------------------------------
def start(self):
"""
Start the metadata subsystem.
"""
self._composition_worker.start()
def terminate(self):
"""
Cleanup & terminate the director.
"""
self._ast_queue.put(None)
try:
self._composition_worker.join()
except RuntimeError:
pass
# best effort to free up resources & improve interpreter spindown
del self._special_coverage
del self._database_coverage
del self._coverage_switched_callbacks
del self._coverage_modified_callbacks
del self._coverage_created_callbacks
del self._coverage_deleted_callbacks
del self._composition_cache
#--------------------------------------------------------------------------
# Properties
#--------------------------------------------------------------------------
@property
def coverage(self):
"""
Return the active database coverage.
"""
return self.get_coverage(self.coverage_name)
@property
def aggregate(self):
"""
Return the database coverage aggregate.
"""
return self._special_coverage[AGGREGATE]
@property
def coverage_names(self):
"""
Return the list or loaded / composed database coverage names.
"""
return list(self._database_coverage)
@property
def special_names(self):
"""
Return the list of special (director maintained) coverage names.
"""
return list(self._special_coverage)
@property
def all_names(self):
"""
Return the names of both special & loaded/composed coverage data.
"""
return self.coverage_names + self.special_names
#----------------------------------------------------------------------
# Callbacks
#----------------------------------------------------------------------
def coverage_switched(self, callback):
"""
Subscribe a callback for coverage switch events.
"""
register_callback(self._coverage_switched_callbacks, callback)
def _notify_coverage_switched(self):
"""
Notify listeners of a coverage switch event.
"""
notify_callback(self._coverage_switched_callbacks)
def coverage_modified(self, callback):
"""
Subscribe a callback for coverage modification events.
"""
register_callback(self._coverage_modified_callbacks, callback)
def _notify_coverage_modified(self):
"""
Notify listeners of a coverage modification event.
"""
notify_callback(self._coverage_modified_callbacks)
def coverage_created(self, callback):
"""
Subscribe a callback for coverage creation events.
"""
register_callback(self._coverage_created_callbacks, callback)
def _notify_coverage_created(self):
"""
Notify listeners of a coverage creation event.
"""
notify_callback(self._coverage_created_callbacks)
def coverage_deleted(self, callback):
"""
Subscribe a callback for coverage deletion events.
"""
register_callback(self._coverage_deleted_callbacks, callback)
def _notify_coverage_deleted(self):
"""
Notify listeners of a coverage deletion event.
"""
notify_callback(self._coverage_deleted_callbacks)
def refreshed(self, callback):
"""
Subscribe a callback for director refresh events.
"""
return register_callback(self._refreshed_callbacks, callback)
def _notify_refreshed(self):
"""
Notify listeners of a director refresh event.
"""
notify_callback(self._refreshed_callbacks)
def unregister_refreshed(self, callback_ref):
"""
Unsubscribe a callback reference from director refresh events.
"""
unregister_callback(self._refreshed_callbacks, callback_ref)
#----------------------------------------------------------------------
# Batch Loading
#----------------------------------------------------------------------
def resume_aggregation(self):
"""
Resume automatic updating of the coverage aggregate.
"""
assert self._aggregation_suspended
self._refresh_aggregate()
self._aggregation_suspended = False
def suspend_aggregation(self):
"""
Suspend the coverage aggregate from being automatically updated.
It is performant to suspend/resume aggregation if loading a number
of individual coverage files. This will prevent the aggregate
coverage set from being re-computed multiple times.
"""
self._aggregation_suspended = True
#----------------------------------------------------------------------
# Coverage Loading
#----------------------------------------------------------------------
def load_coverage_batch(self, filepaths, batch_name, progress_callback=logger.debug):
"""
Create a new database coverage mapping from a list of coverage files.
Returns a tuple of (coverage, errors)
"""
errors = collections.defaultdict(list)
aggregate_addresses = set()
# unsupress NO_COVERAGE_ERROR per-load, instead of per-session
self.suppressed_errors.discard(CoverageMissingError)
start = time.time()
#----------------------------------------------------------------------
for i, filepath in enumerate(filepaths, 1):
logger.debug("-"*50)
progress_callback("Aggregating batch data %u/%u" % (i, len(filepaths)))
# attempt to load coverage data from disk
try:
coverage_file = self.reader.open(filepath)
coverage_addresses = self._extract_coverage_data(coverage_file)
# save and suppress warnings generated from loading coverage files
except CoverageParsingError as e:
errors[CoverageParsingError].append(e)
continue
# ensure some data was actually extracted from the log
if not coverage_addresses:
errors[CoverageMissingError].append(CoverageMissingError(filepath))
continue
# save the attribution data for this coverage data
for address in coverage_addresses:
if address in self.metadata.nodes:
self.owners[address].add(filepath)
# aggregate all coverage data into a single set of addresses
aggregate_addresses.update(coverage_addresses)
if not aggregate_addresses:
return (None, errors)
# save the batched coverage data to the director
coverage = self.create_coverage(batch_name, aggregate_addresses)
# evaluate coverage
if not coverage.nodes:
errors[CoverageMappingAbsent].append(CoverageMappingAbsent(coverage))
elif coverage.suspicious:
errors[CoverageMappingSuspicious].append(CoverageMappingSuspicious(coverage))
#----------------------------------------------------------------------
end = time.time()
logger.debug("Batch loading took %f seconds" % (end-start))
# return the created coverage name
return (coverage, errors)
def load_coverage_files(self, filepaths, progress_callback=logger.debug):
"""
Create new database coverage mappings from a list of coverage files.
Returns a tuple of (created_coverage, errors)
"""
errors = collections.defaultdict(list)
all_coverage = []
# unsupress NO_COVERAGE_ERROR per-load, instead of per-session
self.suppressed_errors.discard(CoverageMissingError)
start = time.time()
#----------------------------------------------------------------------
#
# stop the director's aggregate set from recomputing after each new
# coverage mapping is created. instead, we want to wait till *all* new
# files have been loaded and mapped, computing the new aggregate only
# at very end. this is far more performant.
#
self.suspend_aggregation()
#
# loop through the list of filepaths we have been given and begin
# the process of loading the coverage data from disk, and normalizing
# it for the director to consume
#
for i, filepath in enumerate(filepaths, 1):
logger.debug("-"*50)
progress_callback("Loading coverage %u/%u" % (i, len(filepaths)))
# attempt to load coverage data from disk
try:
coverage_file = self.reader.open(filepath)
coverage_addresses = self._extract_coverage_data(coverage_file)
# save and suppress warnings generated from loading coverage files
except CoverageParsingError as e:
errors[CoverageParsingError].append(e)
continue
# ensure some data was actually extracted from the log
if not coverage_addresses:
errors[CoverageMissingError].append(CoverageMissingError(filepath))
continue
#
# request a name for the new coverage mapping that the director will
# generate from the loaded coverage data
#
coverage_name = self._suggest_coverage_name(filepath)
coverage = self.create_coverage(coverage_name, coverage_addresses, filepath)
# save the attribution data for this coverage data
for address in coverage.data:
if address in self.metadata.nodes: # TODO/UNMAPPED: support right click unmapped addrs
self.owners[address].add(filepath)
# evaluate coverage
if not coverage.nodes:
errors[CoverageMappingAbsent].append(CoverageMappingAbsent(coverage))
elif coverage.suspicious:
errors[CoverageMappingSuspicious].append(CoverageMappingSuspicious(coverage))
# add the newly created coverage to the list of coverage to be returned
all_coverage.append(coverage)
#
# resume the director's aggregation service, triggering an update to
# recompute the aggregate set with the newly loaded coverage
#
progress_callback("Recomputing coverage aggregate...")
self.resume_aggregation()
#----------------------------------------------------------------------
end = time.time()
logger.debug("File loading took %f seconds" % (end-start))
# all done
return (all_coverage, errors)
def _extract_coverage_data(self, coverage_file):
"""
Internal routine to extract relevant coverage data from a CoverageFile.
"""
database_target = self.metadata.filename
target_names = [database_target] + self._target_whitelist
#
# inspect the coverage file and extract the module name that seems to
# match the executable loaded by the disassembler (fuzzy lookup) or
# otherwise aliased by the user through the fallback dialog
#
for name in target_names:
module_name = self._find_fuzzy_name(coverage_file, name)
if module_name:
break
#
# if the fuzzy name lookup failed and there are named modules in the
# coverage file, then we will show them to the user and see if they
# can pick out a matching module to load coverage from
#
if not module_name and coverage_file.modules:
#
# earlier in this load, the user opted to ignore future attempts
# to alias or select coverage data. this is useful when trying to
# load a batch of coverage files, where some coverage files
# contain data, but none relevant to this database.
#
if CoverageMissingError in self.suppressed_errors:
return []
#
# show the module selection dialog to the user, and wait for them
# to select something, or close the dialog
#
dialog = ModuleSelector(database_target, coverage_file.modules, coverage_file.filepath)
result = dialog.exec_()
# check if the user opted to ignore future warnings for missing coverage
if dialog.ignore_missing:
self.suppressed_errors.add(CoverageMissingError)
#
# if the user closed the dialog without selecting a name, there's
# nothing we can do for them. return an empty set of coverage data
#
if not result:
return []
# the user selected a module name! use that to extract coverage
module_name = dialog.selected_name
# the user opted to save the selected name as an 'alias'
if dialog.remember_alias:
self._target_whitelist.append(module_name)
#
# (module, offset, size) style logs (eg, drcov)
#
imagebase = self.metadata.imagebase
try:
coverage_blocks = coverage_file.get_offset_blocks(module_name)
coverage_addresses = [imagebase+offset for bb_start, bb_len in coverage_blocks for offset in xrange(bb_start, bb_start+bb_len)]
return coverage_addresses
except NotImplementedError:
pass
#
# (module, offset) style logs (eg, mod+off)
#
try:
coverage_offsets = coverage_file.get_offsets(module_name)
coverage_addresses = [imagebase+offset for offset in coverage_offsets]
return coverage_addresses
except NotImplementedError:
pass
#
# (absolute address) style log (eg, instruction/bb trace)
#
try:
coverage_addresses = coverage_file.get_addresses(module_name)
return coverage_addresses
except NotImplementedError:
pass
# well, this one is probably the fault of the CoverageFile author...
raise NotImplementedError("Incomplete CoverageFile implementation")
def _suggest_coverage_name(self, filepath):
"""
Return a suggested coverage name for the given filepath.
"""
coverage_name = os.path.basename(filepath)
coverage = self.get_coverage(coverage_name)
# no internal conflict, the filename is a unique enough coverage name
if not coverage:
return coverage_name
#
# if there is an existing coverage mapping under this name, odds are
# that the user is re-loading the same coverage file in which case the
# director will overwrite the old DatabaseCoverage object.
#
# however, we have to be careful for the case where the user loads a
# coverage file from a different directory under the same name
#
# e.g:
# - C:\coverage\foo.log
# - C:\coverage\testing\foo.log
#
# in these cases, we will append a suffix to the new coverage file
#
# assign a suffix to the coverage_name in the event of a collision
if coverage.filepath != filepath:
# find a suitable suffix
for i in xrange(2, 1000000):
new_name = "%s_%u" % (coverage_name, i)
if not self.get_coverage(new_name):
break
# save the suffixed name to the return value
coverage_name = new_name
# return the suggested coverage name for the given filepath
return coverage_name
def _find_fuzzy_name(self, coverage_file, target_name):
"""
Return the closest matching module name in the given coverage file.
"""
target_name = target_name.lower()
#
# 0. Pre-process module names, strip filepath if present
#
clean_module_names = {}
for module_name_raw in coverage_file.modules:
# trim 'path' from a 'module name' entry... if present (uncommon)
module_name = os.path.basename(module_name_raw)
#
# if this triggers, it's probably because the coverage file is
# using full filepaths for 'module names', and that there was
# two unique filepaths with the same module name, eg:
#
# - C:\foo.dll
# - C:\bar\foo.dll
#
# this should be super rare, but we'll just revert to using the
# full / unprocessed paths and bail...
#
if module_name in clean_module_names:
clean_module_names = {name: name for name in coverage_file.modules}
break
clean_module_names[module_name] = module_name_raw
#
# 1. exact, case-insensitive filename matching
#
for module_name in clean_module_names:
if target_name == module_name.lower():
return clean_module_names[module_name]
#
# 2. exact, case-insensitive filename matching
#
for module_name in clean_module_names:
if target_name == module_name.lower():
return clean_module_names[module_name]
#
# 2. cleave the extension from the target module name (the source)
# and try again to see if matches anything in the coverage file
#
target_name, extension = os.path.splitext(target_name)
for module_name in clean_module_names:
if target_name == module_name.lower():
return clean_module_names[module_name]
# too risky to do fuzzy matching on short names...
if len(target_name) < 6:
return None
#
# 3. try to match *{target_name}*{extension} in module_name, assuming
# target_name is more than 6 characters and there is no other ambiguity
#
possible_names = []
for module_name in clean_module_names:
if target_name in module_name.lower() and extension in module_name.lower():
possible_names.append(clean_module_names[module_name])
# there were no matches on the wildcarding, so we're done
if not possible_names:
return None
#
# if there is multiple potential matches it is too risky to pick one,
# so we are not going to return anything as a viable match
#
if len(possible_names) > 1:
return None
return possible_names[0]
#----------------------------------------------------------------------
# Coverage Management
#----------------------------------------------------------------------
def get_address_coverage(self, address):
"""
Return a list of database coverage objects containing the given address.
"""
found = []
for name, db_coverage in iteritems(self._database_coverage):
if address in db_coverage.coverage:
found.append(db_coverage)
return found
def get_address_file(self, address):
"""
Return a list of coverage filepaths containing the given address.
"""
node = self.metadata.get_node(address)
if not node:
return []
return list(self.owners.get(node.address, []))
def create_coverage(self, coverage_name, coverage_data, coverage_filepath=None):
"""
Create a new database coverage mapping from the given data.
"""
return self.update_coverage(coverage_name, coverage_data, coverage_filepath)
def select_coverage(self, coverage_name):
"""
Activate a loaded coverage mapping by name.
"""
logger.debug("Selecting coverage %s" % coverage_name)
# ensure a coverage mapping actually exists for the given coverage_name
if not (coverage_name in self.all_names):
raise ValueError("No coverage matching '%s' was found" % coverage_name)
# if the given name is already active, there's nothing to do
if self.coverage_name == coverage_name:
return
#
# save the given coverage_name as the active name. this effectively
# changes which coverage mapping the director considers active.
#
self.coverage_name = coverage_name
# notify any listeners that we have switched our active coverage
self._notify_coverage_switched()
def update_coverage(self, coverage_name, coverage_data, coverage_filepath=None):
"""
Create or update a databases coverage mapping.
"""
assert not (coverage_name in RESERVED_NAMES)
updating_coverage = coverage_name in self.coverage_names
if updating_coverage:
logger.debug("Updating coverage %s" % coverage_name)
else:
logger.debug("Adding coverage %s" % coverage_name)
# create a new database coverage mapping from the given coverage data
new_coverage = DatabaseCoverage(
self.palette,
coverage_name,
coverage_filepath,
coverage_data
)
new_coverage.update_metadata(self.metadata)
new_coverage.refresh()
#
# coverage mapping complete, looks like we're good. commit the new
# coverage to the director's coverage table and surface it for use.
#
# note that this will overwrite an existing coverage mapping present
# under the same name
#
self._commit_coverage(coverage_name, new_coverage)
# assign a shorthand alias (if available) to new coverage additions
if not updating_coverage:
self._request_shorthand_alias(coverage_name)
# notify any listeners that we have added or updated coverage
if updating_coverage:
self._notify_coverage_modified()
else:
self._notify_coverage_created()
# return the created/updated coverage
return new_coverage
def _commit_coverage(self, coverage_name, new_coverage):
"""
Internal add/update of coverage.
This will automatically update the director's aggregate.
"""
#
# if there exists a coverage mapping under the given coverage_name we
# are trying to add/update, we first must remove anything it has
# contributed to the aggregate before we dispose of its data
#
if coverage_name in self.coverage_names:
old_coverage = self._database_coverage[coverage_name]
self.aggregate.subtract_data(old_coverage.data)
if not self._aggregation_suspended:
self._refresh_aggregate()
#
# this is the critical point where we actually integrate the newly
# built coverage into the director or replacing an existing entry
#
self._database_coverage[coverage_name] = new_coverage
# (re)-add the newly loaded/updated coverage data to the aggregate
self.aggregate.add_data(new_coverage.data)
if not self._aggregation_suspended:
self._refresh_aggregate()
def delete_coverage(self, coverage_name):
"""
Delete a database coverage mapping by name.
"""
#
# if the delete request targets the currently active coverage, we want
# to switch into a safer coverage set to try and avoid any ill effects.
#
if coverage_name in [self.coverage_name, AGGREGATE]:
self.select_coverage(NEW_COMPOSITION)
# attempt to delete the requested coverage_name
if coverage_name in self.coverage_names:
self._delete_user_coverage(coverage_name)
elif coverage_name == AGGREGATE:
self._delete_aggregate_coverage()
else:
raise ValueError("Cannot delete %s, does not exist" % coverage_name)
# notify any listeners that we have deleted coverage
self._notify_coverage_deleted()
def _delete_user_coverage(self, coverage_name):
"""
Delete a user created database coverage mapping by name.
"""
# release the shorthand alias held by this coverage
self._release_shorthand_alias(coverage_name)
# remove the database coverage mapping from the director's coverage map
coverage = self._database_coverage.pop(coverage_name)
# TODO/FUTURE: check if there's any references to the coverage object?
# remove the coverage data this mapping contributed to the aggregate
self.aggregate.subtract_data(coverage.data)
if not self._aggregation_suspended:
self._refresh_aggregate()
def _delete_aggregate_coverage(self):
"""
Delete the aggregate set, effectively clearing all loaded coverage.
"""
# loop through all the loaded coverage sets and release them
for coverage_name in self.coverage_names:
self._release_shorthand_alias(coverage_name)
self._database_coverage.pop(coverage_name)
# TODO/FUTURE: check if there's any references to the coverage aggregate?
# assign a new, blank aggregate set
self._special_coverage[AGGREGATE] = DatabaseCoverage(self.palette, AGGREGATE)
self._refresh_aggregate() # probably not needed
def get_coverage(self, name):
"""
Retrieve coverage data for the requested coverage_name.
"""
# if the given name was an alias, this will dereference it
coverage_name = self._alias2name.get(name, name)
# attempt to retrieve the requested coverage
if coverage_name in self.coverage_names:
return self._database_coverage[coverage_name]
if coverage_name in self.special_names:
return self._special_coverage[coverage_name]
# could not locate coverage
return None
def get_coverage_string(self, coverage_name):
"""
Retrieve a detailed coverage string for the given coverage_name.
"""
# special cases that should be static
if coverage_name == HOT_SHELL or coverage_name == NEW_COMPOSITION:
return coverage_name
symbol = self.get_shorthand(coverage_name)
coverage = self.get_coverage(coverage_name)
# compute coverage percent & render it in string form
percent = coverage.instruction_percent*100
percent_str = "%5.2f" % percent
#
# build and return a generic detailed coverage string
# eg: 'A - 73.45% - drcov.boombox.exe.03820.0000.proc.log'
#
return "%s - %s%% - %s" % (symbol, percent_str, coverage_name)
#----------------------------------------------------------------------
# Aliases
#----------------------------------------------------------------------
def alias_coverage(self, coverage_name, alias):
"""
Assign an alias to a loaded database coverage mapping.
"""
assert not (alias in self.all_names)
assert not (alias in RESERVED_NAMES)
self._alias_coverage(coverage_name, alias)
def _alias_coverage(self, coverage_name, alias):
"""
Assign alias with no restrictions. Internal use only.
"""
#
# if we are overwriting a known alias, we should remove its
# inverse mapping reference in the name --> [aliases] map first
#
if alias in self._alias2name:
self._name2alias[self._alias2name[alias]].remove(alias)
# save the new alias
self._alias2name[alias] = coverage_name
self._name2alias[coverage_name].add(alias)
def get_aliases(self, coverage_name):
"""
Retrieve alias set for the requested coverage_name.
"""
return self._name2alias[coverage_name]
def get_shorthand(self, coverage_name):
"""
Retrieve shorthand symbol for the requested coverage.
"""
# reduce the coverage's aliases to only shorthand candidates
try:
shorthand = self._name2alias[coverage_name] & SHORTHAND_ALIASES
except KeyError:
return None
# there should only ever be one shorthand symbol for a given coverage
assert len(shorthand) < 2
# pop the single shorthand symbol (if one is even aliased)
try:
return shorthand.pop()
except KeyError:
return None
def peek_shorthand(self):
"""
Peek at the next available shorthand symbol.
"""
try:
return self._shorthand[0]
except IndexError:
return None
def _request_shorthand_alias(self, coverage_name):
"""
Assign the next shorthand A-Z alias to the given coverage.
"""
logger.debug("Requesting shorthand alias for %s" % coverage_name)
assert coverage_name in self.coverage_names
# get the next available symbol (A-Z) from the shorthand pool
try:
symbol = self._shorthand.popleft()
except IndexError:
return None
# alias the symbol to the given coverage_name & return it
self._alias_coverage(coverage_name, symbol)
return symbol
def _release_shorthand_alias(self, coverage_name):
"""
Release the shorthand alias of the given coverage_name.
"""
logger.debug("Releasing shorthand alias for %s" % coverage_name)
assert coverage_name in self.coverage_names
# get the shorthand symbol for the given coverage
symbol = self.get_shorthand(coverage_name)
# if there was no symbol assigned, there's nothing to do
if not symbol:
return
# delete the shorthand symbol from the alias maps
self._name2alias[coverage_name].remove(symbol)
self._alias2name.pop(symbol)
# add the symbol back to the end of the shorthand pool
self._shorthand.append(symbol)
#
# in the event that all shorthand aliases have been released back to
# us, we rest the shorthand list so that new symbols will begin from
# the start of the alphabet (A, B, C ...)
#
if len(self._shorthand) == len(ASCII_SHORTHAND):
self._shorthand = collections.deque(ASCII_SHORTHAND)
#----------------------------------------------------------------------
# Composing
#----------------------------------------------------------------------
def add_composition(self, composite_name, ast):
"""
Evaluate and add a new composition to the director.
"""
assert not (composite_name in RESERVED_NAMES)
updating_coverage = composite_name in self.coverage_names
logger.debug("Adding composition %s" % composite_name)
# evaluate the last AST into a coverage set
composite_coverage = self._evaluate_composition(ast)
composite_coverage.name = composite_name
# save the evaluated coverage under the given name
self._commit_coverage(composite_name, composite_coverage)
# assign a shorthand alias (if available) to new coverage additions
if not updating_coverage:
self._request_shorthand_alias(composite_name)
# notify any listeners that we have added or updated coverage
if updating_coverage:
self._notify_coverage_modified()
else:
self._notify_coverage_created()
def cache_composition(self, ast, force=False):
"""
Evaluate & cache the given composition (asynchronously).
"""
assert ast
#
# normally, we only pro-actively evaluate/cache if the hotshell is
# active, but we can also allow the caller to force a cache to occur
#
if self.coverage_name == HOT_SHELL or force:
self._ast_queue.put(ast)
def _async_evaluate_ast(self):
"""
Asynchronous composition evaluation worker loop.
"""
logger.debug("Starting EvaluateAST thread...")
while True:
# get the next coverage expression (an AST) to evaluate
ast = self._ast_queue.get()
if ast == None:
break
# produce a single composite coverage mapping as described by the AST
composite_coverage = self._evaluate_composition(ast)
# we always save the most recent composite to the hotshell entry
self._special_coverage[HOT_SHELL] = composite_coverage
#
# if the hotshell entry is the active coverage selection, notify
# listeners of its update
#
if self.coverage_name == HOT_SHELL:
self._notify_coverage_modified()
# loop and wait for the next AST to evaluate
# thread exit
logger.debug("Exiting EvaluateAST thread...")
def _evaluate_composition(self, ast):
"""
Evaluate the coverage composition described by the AST.
"""
# if the AST is effectively 'null', return a blank coverage set
if isinstance(ast, TokenNull):
return DatabaseCoverage(self.palette)
#
# the director's composition evaluation code (this function) is most
# generally called via the background caching evaluation thread known
# as self._composition_worker. But this function can also be called
# inline via the 'add_composition' function from a different thread
# (namely, the main thread)
#
# because of this, we must gate the resources that AST evaluation code
# operates on behind a lock, restricting the code to one thread.
#
# should we call _evaluate_composition from the context of the main
# thread, it is important that we do so in a pseudo non-blocking way
# such that we don't hang the UI. await_lock(...) will allow the Qt
# main thread to yield to other threads while waiting for the lock.
#
await_lock(self._composition_lock)
# recursively evaluate the AST
composite_coverage = self._evaluate_composition_recursive(ast)
# map the composited coverage data to the database metadata
composite_coverage.update_metadata(self.metadata)
composite_coverage.refresh() # TODO/FUTURE: hash refresh?
# done operating on shared data (coverage), release the lock
self._composition_lock.release()
# return the evaluated composition
return composite_coverage
def _evaluate_composition_recursive(self, node):
"""
The internal (recursive) AST evaluation routine.
"""
#
# if the current AST node is a logic operator, we need to evaluate the
# expressions that make up its input. only once each operand has been
# concretized is it appropriate for us to operate on them
#
if isinstance(node, TokenLogicOperator):
#
# collect the left and right components of the logical operation
# eg:
# op1 = DatabaseCoverage for 'A'
# op2 = DatabaseCoverage for 'B'
#
op1 = self._evaluate_composition_recursive(node.op1)
op2 = self._evaluate_composition_recursive(node.op2)
#
# before computing a new composition, we first compute a low-cost
# 'hash' of the desired operation. this hash can be used to
# identify an existing (eg, previously computed) result, retrieving
# it from an LRU based cache that holds compositions created by the
# AST evaluation process.
#
# the 'hash' is actually computed as a product of the operator
# that would normally combine the two coverage sets.
#
# for example, when evaluating a coverage composition, the logical
# operators (eg |, &, ^), it does not matter which side of the
# equation the coverage components fall on.
#
# eg:
# (A | B) == (B | A)
#
# while arithmetic operations (-) will produce different results
#
# (A - B) != (B - A)
#
# so if we are being asked to compute a composition of (A | B),
# we first compute:
#
# composition_hash = hash(A) | hash(B)
#
# using the composition_hash, we can check the LRU cache for a
# previous computation of the composition (A | B).
#
# the possibility of collisions are generally higher with this
# form of 'hash', but I still expect them to be extremely rare...
#
composition_hash = node.operator(op1.coverage_hash, op2.coverage_hash)
#
# evaluating an AST produces lots of 'transient' compositions. To
# mitigate unnecessary re-computation, we maintain a small LRU cache
# of these compositions to draw from during subsequent evaluations.
#
# eg:
# evaluating the input
#
# (A | B) - (C | D)
#
# produces
#
# COMP_1 = (A | B)
# COMP_2 = (C | D)
# COMP_3 = COMP_1 - COMP_2
#
# in the example above, COMP_3 is the final evaluated result that
# will be returned to the user, while COMP_1/COMP_2 would normally
# be discarded. Instead, we cache all of these compositions
# (1, 2, 3) as they may be useful to us in future evaluations.
#
# later, if the user then choses to evaluate (A | B) - (Z | D), our
# cache can retrieve the fully computed (A | B) composition
# assuming it has not been evicted.
#
# this makes Lighthouse far more performant for repeated operations
#
# check the cache to see if this composition was recently computed
cached_coverage = self._composition_cache[composition_hash]
# if the composition was found in the cache, return that for speed
if cached_coverage:
return cached_coverage
#
# using the collected components of the logical operation, we
# compute the coverage mask defined by this TokenLogicOperator
#
coverage_mask = node.operator(op1.coverage, op2.coverage)
#
# now that we have computed the requested coverage mask (a bitmap),
# we use the mask to generate a new DatabaseCoverage mapping.
#
new_composition = DatabaseCoverage(self.palette, data=coverage_mask)
# cache & return the newly computed composition
self._composition_cache[composition_hash] = new_composition
return new_composition
#
# if the current AST node is a coverage token, we need simply need to
# return its associated DatabaseCoverage.
#
elif isinstance(node, TokenCoverageSingle):
return self._evaluate_coverage(node)
#
# unknown token? (this should never happen)
#
raise ValueError("Invalid AST Token in Composition Tree")
def _evaluate_coverage(self, coverage_token):
"""
Evaluate a TokenCoverageSingle AST token.
Returns an existing database coverage mapping.
"""
assert isinstance(coverage_token, TokenCoverageSingle)
return self.get_coverage(self._alias2name[coverage_token.symbol])
#----------------------------------------------------------------------
# Refresh
#----------------------------------------------------------------------
def refresh(self):
"""
Complete refresh of the director and mapped coverage.
"""
if disassembler[self.metadata.lctx].busy:
disassembler.warning("Cannot refresh Lighthouse while the disassembler is busy...")
return
disassembler.show_wait_box("Refreshing Lighthouse...")
self._refresh()
disassembler.hide_wait_box()
@catch_errors
def _refresh(self):
"""
Internal refresh routine, wrapped to help catch bugs for now.
"""
#
# (re) build our metadata cache of the underlying database
#
if not is_mainthread():
self.metadata.refresh(metadata_progress)
#
# NOTE: optionally, we call the async vesrion here so that we do not pin
# the mainthread for disassemblers that will primarily read from the
# database in a background thread (eg, Binja)
#
# for example, this refresh action may be called from a UI event or
# clicking 'Open Coverage Overview' (eg, the mainthread). if we pin
# the mainthread while doing database reads from a background thread,
# we cannot post UI updates such as progress updates to the user
#
# using an async refresh allows us to 'softly' spin the main (UI)
# thread and get UI updates while the refresh runs
#
else:
future = self.metadata.refresh_async(metadata_progress, force=True)
self.metadata.go_synchronous()
await_future(future)
# (re) map each set of loaded coverage data to the database
if self.coverage_names:
self._refresh_database_coverage()
# notify of full-refresh
self._notify_refreshed()
def refresh_theme(self):
"""
Refresh UI facing elements to reflect the current theme.
Does not require @disassembler.execute_ui decorator as no Qt is touched.
"""
for coverage in self._database_coverage.values():
coverage.refresh_theme()
for coverage in self._special_coverage.values():
coverage.refresh_theme()
def _refresh_database_coverage(self):
"""
Refresh all the database coverage mappings managed by the director.
"""
logger.debug("Refreshing database coverage mappings")
for i, name in enumerate(self.all_names, 1):
logger.debug(" - %s" % name)
disassembler.replace_wait_box(
"Refreshing coverage mapping %u/%u" % (i, len(self.all_names))
)
coverage = self.get_coverage(name)
coverage.update_metadata(self.metadata)
coverage.refresh()
def _refresh_aggregate(self):
"""
Refresh the aggregate database coverage mapping.
"""
self.aggregate.update_metadata(self.metadata)
self.aggregate.refresh()
#------------------------------------------------------------------------------
# Composition Cache
#------------------------------------------------------------------------------
DEFAULT_CACHE_CAPACITY = 30
class CompositionCache(object):
"""
A simple LRU cache to hold coverage compositions.
"""
def __init__(self, capacity=DEFAULT_CACHE_CAPACITY):
self._cache = collections.OrderedDict()
self._capacity = capacity
def __getitem__(self, key):
"""
Get an entry from the cache.
"""
result = self._cache.pop(key, None)
# cache hit, raise priority of this item
if result:
self._cache[key] = result
# return the cache entry (or None)
return result
def __setitem__(self, key, value):
"""
Update the cache with the given entry.
"""
result = self._cache.pop(key, None)
# item is already in the cache, touch it.
if result:
self._cache[key] = result
return
# if the cache is full, evict the entry oldest entry
if len(self._cache) > self._capacity:
self._cache.popitem(False)
# insert the new cache entry
self._cache[key] = value