diff --git a/.gitignore b/.gitignore
index f9621c1c7..5fb797eca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,4 +36,7 @@ reach_wrap_output.json
**/resolve_log.out*
*.facts
-*.facts.zst
\ No newline at end of file
+*.facts.zst
+
+# rust
+*target*
diff --git a/Makefile b/Makefile
index e1e1208c4..c4183913b 100644
--- a/Makefile
+++ b/Makefile
@@ -58,7 +58,7 @@ check-with-klee:
$(MAKE) check RESOLVE_BUILD_KLEE=ON
test: configure
- cmake --build $(RESOLVE_CMAKE_BUILD_DIR) --target test-CVEAssert test-libresolve
+ cmake --build $(RESOLVE_CMAKE_BUILD_DIR) --target test-CVEAssert test-libresolve test-resolve-reach
test-with-klee:
$(MAKE) test RESOLVE_BUILD_KLEE=ON
diff --git a/docs/components/facts.md b/docs/components/facts.md
index cea5a5ba8..212f6fbb3 100644
--- a/docs/components/facts.md
+++ b/docs/components/facts.md
@@ -1,8 +1,46 @@
# Facts
-Fact generation is a static program analysis technique that extracts structured information about a program from its source code or intermediate representation (i.e. [LLVM-IR](https://llvm.org/docs/LangRef.html)). A *fact* is a piece of information that describes some property of a program. Facts can be used to describe relationships between code and data. The `EnhancedFacts` pass plugin constructs program facts based on the program's control- and data-flow, and embeds these facts into custom ELF sections for downstream analysis.
+Facts are information about a program, extracted from [LLVM IR](https://llvm.org/docs/LangRef.html) at compile-time. Each fact describes a program node, property, or relationship. The collection of facts can form a Control Flow Graph, though they carry additional metadata beyond just that.
-These facts are compressed with zstd and stored inside a custom ELF section in the compiled binary called `.facts`. Reachability analysis can be performed by the [reach](reach.md) tool, which consumes these facts in its analysis. The [reachability example](../examples/reachability.md) walks through generating and querying facts end-to-end.
+[resolvecc](resolve-cc.md) will produce and embed facts into a `.facts` section inside the compiled ELF in a compact binary format, typically compressed with zstd.
-!!! note
- Developed for easy parsing and to encourage compatibility with third party tools, the facts format can consume quite a bit of storage and memory, particularly when uncompressed, due to being text-based.
+The [reach](reach.md) command consumes these facts from an ELF file, shared objcet, or an extracted `.facts` file. The [reachability example](../examples/reachability.md) shows a complete end-to-end example of this.
+
+## Binary Format Specification
+
+The binary format is typically compressed with zstd when it is attached to compiled objects. A zstd frame can be identified by the leading bytes: `28 B5 2F FD`.
+
+For definitive structure, consult the [Rust schema](https://github.com/riversideresearch/resolve/tree/main/resolve-facts/rs/src/schema.rs).
+
+The uncompressed facts stream has no top-level header. It contains one or more modules in sequence:
+
+```text
+Facts stream
+├── ModuleHeader (16 bytes)
+│ ├── version: u32
+│ ├── node_count: u32
+│ ├── edge_count: u32
+│ └── string_pool_len: u32
+├── Node[node_count] (32 bytes each)
+│ ├── meta: u32 (multiple bitmasks)
+│ ├── idx: u32
+│ ├── name: u32 (string offset)
+│ ├── opcode: u32 (string offset)
+│ ├── source_line: u32
+│ ├── source_col: u32
+│ ├── source_file: u32 (string offset)
+│ └── function_type: u32 (string offset)
+├── Edge[edge_count] (12 bytes each)
+│ ├── src: u32 (NodeID)
+│ ├── dst: u32 (NodeID)
+│ └── kinds: u32 (bitmask)
+├── String pool (string_pool_len bytes)
+│ └── repeated [byte length: u32][UTF-8 bytes ...]
+└── Next module, if present
+```
+
+A node ID is its index in the node array of its module. The `meta` field stores the node type, property flags, linkage, and call type.
+
+The `kinds` field is a bit set. One source and destination pair can have multiple relationships, such as `Calls`, `Contains`, or `ControlFlowTo`.
+
+The string pool has four-byte alignment. The writer adds zero padding after the final string when the pool requires it.
diff --git a/docs/components/reach.md b/docs/components/reach.md
index 56218b260..705ec40df 100644
--- a/docs/components/reach.md
+++ b/docs/components/reach.md
@@ -1,114 +1,96 @@
# Reach
-`reach` performs static reachability queries on `resolve` program metadata. It consumes the [fact files](facts.md) extracted from binaries by `linker` and determines whether a path exists from the program entry point to a specified vulnerability. When a path is found, `reach` packages the results into a `.json` object and writes them either to a user-specified path or to `stdout` by default.
+`resolve reach` determines whether a program entry point can reach a vulnerable function. It uses static control-flow data from RESOLVE facts.
-A Python wrapper, `reach.py`, provides a convenient command-line interface to interact with `reach`. For more information about `reach`, see the [`reach`](https://github.com/riversideresearch/resolve/tree/main/reach) documentation.
+The command accepts facts from these sources:
-!!! tip
- For a hands-on, end-to-end walkthrough of a reachability query, see the [reachability example](../examples/reachability.md).
+- An ELF executable or shared library that contains a `.facts` section.
+- An extracted `.facts` file.
+- A directory that contains one or more `.facts` files.
+- Multiple inputs through repeated `-f` arguments.
-## Developer Information
+The command writes one JSON result for each entry in `vulnerabilities.json`.
-### Run
+## Use the command
+```bash
+resolve reach \
+ --input vulnerabilities.json \
+ --facts program.facts \
+ --output reach.json
```
-cmake -B build && cmake --build build/
-./build/reach --help
-```
-### Description
+The command uses `main` as the default entry point. Use `--entry` to select a different function.
+
+```bash
+resolve reach -i vulnerabilities.json -f program.facts -e service_main
+```
-This development is factored into a library part (under `lib/`) and an
-executable tool (under `src/`) that uses the library.
+If you omit `--output`, the command derives the path from the input name. For example, `vulnerabilities.json` produces `vulnerabilities.reach.json`.
-See the `--help` output for command line arguments/options.
+Use `--src` to read a package version from a Vcpkg manifest. The result becomes unreachable when the installed version is outside the vulnerable range.
-The minimum required arguments for performing a reachability query are
-`--facts_dir` (path to directory containing facts files extracted from
-the program binary), and the `--src` and `--dst` node IDs. The tool
-will construct a control-flow graph from the facts in `facts_dir`, and
-attempt to find the shortest path from `src` to `dst` in it.
+## Dynamic-link analysis
-The `src` and `dst` node IDs should match how they appear in the facts
-files, which is determined by the [**RESOLVE** LLVM
-pass](https://github.com/riversideresearch/resolve/blob/main/resolve-cc/src/ResolveFactsPluginPass.cpp)
-that generates the facts.
+Use `--dynlink` to include compatible external-linkage functions as indirect-call targets.
-For example, if `nodeprops.facts` contains the following line:
+```bash
+resolve reach -i vulnerabilities.json -f program.facts --dynlink
```
-/src/guestbook/src/main.cpp:f_GLOBAL__sub_I_main.cpp,Function
+
+Use `--dlsym-log` with `--dynlink` to restrict those targets to observed symbols.
+
+```bash
+resolve reach \
+ -i vulnerabilities.json \
+ -f program.facts \
+ --dynlink \
+ --dlsym-log dlsym.json
```
-there is a node of type `Function` with ID
-`/src/guestbook/src/main.cpp:f_GLOBAL__sub_I_main.cpp`.
-Arguments can also be specified in an input JSON file instead of as
-command line arguments. See the `--input` argument. If an argument is
-provided in both the input file and at the command line, the command
-line argument takes precedence. The input file format is specified by
-the struct `config` in `src/config.hpp` (the JSON deserializer is
-auto-generated from this definition).
+The log has this structure:
+
+```json
+{
+ "loaded_symbols": [
+ {
+ "symbol": "plugin_entry",
+ "library": "libplugin.so"
+ }
+ ]
+}
+```
-The input file format supports multiple queries (see struct `query`
-and the `queries` field of struct `config` in `src/config.hpp`).
+The graph matches the `symbol` value. The `library` value remains available for future matching changes.
-### Architecture
+## Architecture
-The implementation is organized roughly as follows:
+The Rust command owns input parsing, function lookup, version comparison, and report generation. It calls `libreach` through a small C interface.
```mermaid
-graph LR;
- A[/input.json
cmd args/]-.->B;
- B[main.cpp]-->C;
- C[[facts.hpp]]-->|facts database|D;
- D[[graph.hpp]]-->|constructed graph|E;
- E[[search.hpp]]-->|discovered paths|B;
+graph LR
+ A[vulnerabilities.json] --> B[resolve-reach]
+ F[ELF or binary facts] --> B
+ B --> C[libreach]
+ C --> B
+ B --> O[reachability report]
+```
+
+The command loads all facts once. Then it builds one graph and uses that graph for all unresolved sinks.
+
+## Developer commands
+
+Build the command:
+
+```bash
+cmake -B build
+cmake --build build --target resolve-reach
+```
- F[(nodes.facts
nodeprops.facts
edges.facts)]-.->C;
+Run its existing tests:
- B-.->O[/output.json/]
+```bash
+cmake --build build --target test-resolve-reach
```
-The main reads the input config (plus command line arguments), and
-then uses the functionality declared in `lib/facts.hpp` to load the
-facts files from the disk into an in-memory database. This database is
-used by `lib/graph.hpp` to build a graph, which is passed to
-`lib/search.hpp` for finding paths. Finally, the paths are packaged
-into a JSON object and written to the provided output path or to
-stdout if no path was given.
-
-### Code
-
-Under `lib/`:
-
-- facts.hpp, facts.cpp
- - in-memory representation of fact databases, and loading from .facts files
- - defns related to dlsym loaded symbol logs from dynamic analysis
-- graph.hpp, graph.cpp
- - weighted directed graphs with integer node labels, and functions
- for constructing them from facts databases
- - `handle_map`s for mapping between string node IDs and their
- integer labels (handles)
-- search.hpp, search.cpp
- - pathfinding algorithms on graphs. Currently:
- - BFS
- - Dijkstra's shortest path
- - Yen's K-shortest paths
- - also computing distance maps for KLEE (min distance of each node
- in the graph to a specified destination node)
-- util.hpp
- - misc helper functions
- - `at` function for vector and unordered_map with slightly better
- error reporting
- - `time` function for measuring time to execute a given function
-- distmap.hpp, distmap.cpp
- - compute distance maps and blacklists for directed KLEE
-
-Under `src/`:
-
-- config.hpp
- - specifications of the tool's input and output formats as structs
- - JSON serializers and deserializers are auto-generated from these
- specifications via the Lohmann JSON library
-- main.cpp
- - parse arguments, load facts, build graph, perform queries, output
- results
+See the [reachability example](../examples/reachability.md) for a complete workflow.
diff --git a/docs/development/building-from-source.md b/docs/development/building-from-source.md
index ca7c5e86f..fe855bece 100644
--- a/docs/development/building-from-source.md
+++ b/docs/development/building-from-source.md
@@ -3,6 +3,7 @@
**RESOLVE** has been tested on **Ubuntu 24.04.4 LTS**, but should work on other distributions that can provide the following packages:
- Nightly Rust
+- cbindgen (`cargo install cbindgen --version 0.29.0 --locked`)
- uv
- CMake
- build-essential
diff --git a/docs/examples/reachability.md b/docs/examples/reachability.md
index c9781da14..1b7ffce08 100644
--- a/docs/examples/reachability.md
+++ b/docs/examples/reachability.md
@@ -23,18 +23,15 @@ We want to ask **RESOLVE**: starting from `main`, can execution actually reach `
## A Vulnerability Specification
-First, describe the vulnerability we want to analyze in a JSON file (let's call it [`vulnerabilities.json`](../concepts/vulnerabilities-json.md) on disk). Each entry in the array is a *sink* (a function we would like to try to reach). All of the following fields are required, and will be fed-through into our final report:
+First, describe the vulnerability in a [`vulnerabilities.json`](../concepts/vulnerabilities-json.md) file on disk. Each entry identifies one affected function, which is called a sink.
```json
{
"vulnerabilities": [
{
"cve-id": "CVE-0000-00000",
- "cve-description": "Null pointer dereference reachable from the program entry point.",
"package-name": "reachability-example",
"package-version": "vers:generic/*",
- "cwe-id": "476",
- "cwe-name": "NULL Pointer Dereference",
"affected-function": "do_npd",
"affected-file": "main.c"
}
@@ -53,34 +50,26 @@ Reachability analysis runs on program *facts* (see: [RESOLVE facts](../component
resolvecc main.c -o main
```
-## Extracting the Facts
-
-Next, pull the embedded facts back out of the binary into a `main.facts` file with `resolve get-facts`:
-
-```bash
-resolve get-facts -i main
-```
-
-This writes `main.facts` (alongside a compressed `main.facts.zst`) into the current directory.
-
## Running the Reachability Query
-Now we have everything [`resolve reach`](../components/reach.md) needs: the vulnerability specification and the facts. Point it at both and choose an output path for the report:
+[`resolve reach`](../components/reach.md) reads embedded facts directly from the compiled ELF. Pass the path of the program and select an output file:
```bash
-resolve reach -i vulnerabilities.json -f main.facts -o out.json
+resolve reach -i vulnerabilities.json -f main -o out.json
```
!!! tip
If your entry point is not `main`, pass `-e ` to `resolve reach`. For projects with a vcpkg source tree, pass `-s ` so the report can additionally check whether the pinned package version falls in the vulnerable range.
-`resolve reach` locates the entry point (`main` by default), locates each sink in the facts, and searches the control-flow graph for a path between them. Along the way it prints what it found:
+!!! note
+ If you need a separate facts file, use `resolve get-facts -i main`. This command writes `main.facts` and `main.facts.zst`. You can pass `main.facts` to `resolve reach`.
+
+`resolve reach` locates the entry point and each sink. Then it searches the control-flow graph for a path.
```txt
-Found function 'main' in module 'src/main.c'
-Found function 'do_npd' in module 'src/main.c'
-[RW]: Invoking reach 'reach -f main.facts -i reach_wrap_input.json -o reach_wrap_output.json'
-[RW]: Wrote out.json.
+[REACH] Loaded 1 facts modules from 1 input files.
+[REACH] Built a libreach graph with 3 edges.
+[REACH] Wrote 'out.json'.
```
## Interpreting the Report
@@ -97,13 +86,13 @@ The report in `out.json` classifies each sink and, when it is reachable, spells
"conclusion": "Statically Reachable",
"reason": "Control Flow Graph analysis found the following candidate path...",
"call_path": [
- "Function(main) ((1556769911, 9))",
- "DirectCall -> Function(do_npd) ((1556769911, 1))"
+ "Function(main) ((0, 9))",
+ "DirectCall -> Function(do_npd) ((0, 1))"
],
"control_flow_path": [
- "Function(main) ((1556769911, 9))",
- "Contains -> BasicBlock() ((1556769911, 10))",
- "DirectCall -> Function(do_npd) ((1556769911, 1))"
+ "Function(main) ((0, 9))",
+ "Contains -> BasicBlock(0) ((0, 10))",
+ "DirectCall -> Function(do_npd) ((0, 1))"
]
}
}
@@ -111,10 +100,10 @@ The report in `out.json` classifies each sink and, when it is reachable, spells
}
```
-The `call_path` is the human-readable answer: `main` makes a `DirectCall` to `do_npd`, so the vulnerability is reachable. The `control_flow_path` is the same route at basic-block granularity.
+`call_path` gives an exact answer here: `main` makes a `DirectCall` to `do_npd`, so the vulnerability is statically reachable! The `control_flow_path` is the same route at basic-block granularity.
!!! note
- The classification is **potentially reachable (statically reachable)**, not **explicitely exploitable**. Reachability analysis only proves that a path exists in the control-flow graph; it does not prove a concrete input can drive execution down that path. Producing such an input is the job of [input synthesis](input-synthesis.md).
+ The classification is **potentially reachable (statically reachable)**, not **explicitly exploitable**. Reachability analysis proves that a control-flow path exists. It does not prove that a concrete input can use that path. [Input synthesis](input-synthesis.md) produces such an input.
### Other Classifications
@@ -126,17 +115,14 @@ Depending on what `resolve reach` finds, a sink can come back as:
| `unreachable` | Not Reachable | The function exists in the program, but no path reaches it from the entry point. |
| `unreachable` | Not Found | The affected function was not found in the compiled program metadata (e.g. it was inlined, dead-code eliminated, or never linked in). |
-## TDLR (Quick Reference)
+## TLDR (Quick Reference)
Given source code, you can run a reachability query with:
```bash
resolvecc main.c -o main
-resolve get-facts -i main
-resolve reach -i vulnerabilities.json -f main.facts -o out.json
+resolve reach -i vulnerabilities.json -f main -o out.json
```
!!! tip
Once a path is confirmed, synthesize a concrete triggering input with input synthesis (above), or instrument a fix at compile time with [remediation](remediation.md).
-
-
diff --git a/examples/misc/eboss_eval/src/analyze-images/compose-analyze-image.yml b/examples/misc/eboss_eval/src/analyze-images/compose-analyze-image.yml
index a678072ec..c95c4e67d 100644
--- a/examples/misc/eboss_eval/src/analyze-images/compose-analyze-image.yml
+++ b/examples/misc/eboss_eval/src/analyze-images/compose-analyze-image.yml
@@ -92,7 +92,6 @@ services:
/opt/resolve/bin/resolve-reach \
-i /challenge/vulnerabilities.json \
-o /facts-dir/reach_out.json \
- -f /facts-dir/build/ \
- -r /opt/resolve/bin/reach
+ -f /facts-dir/build/analyze-image.facts
"
depends_on: [server-remediated]
diff --git a/examples/misc/openssl.sh b/examples/misc/openssl.sh
index b0d504b69..edf5f3865 100755
--- a/examples/misc/openssl.sh
+++ b/examples/misc/openssl.sh
@@ -11,7 +11,7 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
EXTRACT_FACTS_SCRIPT="/opt/resolve/bin/extract_facts.py"
-REACH_WRAPPER="/opt/resolve/bin/resolve-reach"
+REACH_COMMAND="/opt/resolve/bin/resolve-reach"
export CC="/usr/bin/clang"
export CXX="/usr/bin/clang++"
@@ -50,11 +50,10 @@ mkdir openssl_facts
# Run reach analysis
# -------------------
echo "[+] Running reachability analysis."
-"$REACH_WRAPPER" \
+"$REACH_COMMAND" \
-i openssl_vulnerabilities.json \
-o openssl_reach_out.json \
-f openssl_facts/libcrypto.facts \
- -e "CMS_RecipientInfo_decrypt" \
- -r /opt/resolve/bin/reach
+ -e "CMS_RecipientInfo_decrypt"
# TODO: Add remediation portion check for exit code 3 for successful remediation
diff --git a/examples/reachability/main b/examples/reachability/main
deleted file mode 100755
index ef01b5e86..000000000
Binary files a/examples/reachability/main and /dev/null differ
diff --git a/examples/reachability/run.sh b/examples/reachability/run.sh
index 389c16ad9..94671645d 100755
--- a/examples/reachability/run.sh
+++ b/examples/reachability/run.sh
@@ -3,8 +3,6 @@ set -euo pipefail
resolvecc src/main.c -o main
-resolve get-facts -i main
-
-resolve reach -i vulnerabilities.json -f main.facts -o out.json
+resolve reach -i vulnerabilities.json -f main -o out.json
echo "Reachability report written to out.json"
diff --git a/klee/lib/Core/CMakeLists.txt b/klee/lib/Core/CMakeLists.txt
index be5d458ad..021c3ce73 100644
--- a/klee/lib/Core/CMakeLists.txt
+++ b/klee/lib/Core/CMakeLists.txt
@@ -35,8 +35,6 @@ target_link_libraries(kleeCore PRIVATE
kleaverSolver
kleaverExpr
kleeSupport
- libreach
- resolve_facts_llvm
)
llvm_config(kleeCore "${USE_LLVM_SHARED}" core executionengine mcjit native support)
diff --git a/klee/lib/Core/Executor.cpp b/klee/lib/Core/Executor.cpp
index cc5bbe6b6..aae0bc952 100644
--- a/klee/lib/Core/Executor.cpp
+++ b/klee/lib/Core/Executor.cpp
@@ -56,8 +56,6 @@
#include "klee/System/MemoryUsage.h"
#include "klee/System/Time.h"
-#include "resolve_facts_llvm/resolve_facts_llvm.hpp"
-
#include "llvm/ADT/SmallPtrSet.h"
#include "llvm/ADT/StringExtras.h"
#include "llvm/IR/Attributes.h"
@@ -2115,9 +2113,7 @@ void Executor::transferToBasicBlock(BasicBlock *dst, BasicBlock *src,
goto cont;
}
}
- const auto bb_id = resolve::facts.addNode(*dst);
-
- klee_warning(("pruning state: " + std::to_string(bb_id)).c_str());
+ klee_warning("pruning state");
// For debugging
// std::cout << "call stack: " << std::endl;
diff --git a/klee/tools/klee/main.cpp b/klee/tools/klee/main.cpp
index 986888980..e9e2e1558 100644
--- a/klee/tools/klee/main.cpp
+++ b/klee/tools/klee/main.cpp
@@ -26,8 +26,7 @@
#include "reach/distmap.hpp"
#include "reach/facts.hpp"
-#include "reach/graph.hpp"
-#include "resolve_facts_llvm/resolve_facts_llvm.hpp"
+#include "resolve_facts_llvm/binary_facts_llvm.hpp"
#include "klee/Support/CompilerWarning.h"
DISABLE_WARNING_PUSH
@@ -64,7 +63,6 @@ DISABLE_WARNING_POP
#include
#include
#include
-#include
#include
using namespace llvm;
@@ -654,13 +652,12 @@ void build_distmap_blacklist_for_module
const std::unordered_set &bl,
std::unordered_map &distMap,
std::unordered_set &blackList,
+ const resolve::BinaryLLVMFacts &facts,
const llvm::Module &M) {
for (const Function &F : M) {
for (const BasicBlock &BB : F) {
for (const Instruction &I : BB) {
- const auto iid = resolve::facts.addNode(I);
- const auto mid = resolve::facts.getModuleId(I);
- const auto id = std::make_pair(mid, iid);
+ const auto id = facts.getId(I);
if (dm.find(id) != dm.end()) {
distMap[&I] = dm.at(id);
@@ -674,17 +671,12 @@ void build_distmap_blacklist_for_module
}
// Search for function node id that matches name
-std::optional findMatchingFunctionNodeId(const reach_facts::database &db,
- const std::string functionName) {
- //std::regex pattern(".*/__uClibc_main.c:f" + functionName);
- // "/challenge/app/src/libc/misc/internals/__uClibc_main.c:ftarget"
- std::vector matches;
- for (const auto &[node_id, node_type] : db.node_type) {
- if (node_type == resolve_facts::NodeType::Function && db.name.at(node_id).ends_with(functionName)) {
- matches.push_back(node_id);
- }
- }
- if (!matches.size()) {
+std::optional
+findMatchingFunctionNodeId(const facts_rs::FactsBuf *facts,
+ const std::string &functionName) {
+ const auto matches =
+ reach_facts::find_functions_by_name_suffix(facts, functionName);
+ if (matches.empty()) {
return {};
}
if (matches.size() > 1) {
@@ -706,27 +698,23 @@ bool KleeHandler::buildDistMapAndBlackList
return false;
}
+ resolve::BinaryLLVMFacts facts;
for (const auto &M : loadedModules) {
- resolve::getModuleFacts(*M);
+ resolve::getBinaryModuleFacts(facts, *M);
}
- resolve::getModuleFacts(*mainModule);
-
- const auto fcts = resolve::facts;
-
- auto json = fcts.serialize();
-
- auto facts = std::istringstream(json);
- const reach_facts::database db = reach_facts::load(facts, graph::CFG_LOAD_OPTIONS);
+ resolve::getBinaryModuleFacts(facts, *mainModule);
+ const auto serialized = facts.serialize();
// Map target name to node ID
- const auto targetNodeId_opt = findMatchingFunctionNodeId(db, targetFunctionName);
+ const auto targetNodeId_opt =
+ findMatchingFunctionNodeId(serialized.get(), targetFunctionName);
if (!targetNodeId_opt.has_value()) {
klee_warning("no matching node ID for target function %s", targetFunctionName.c_str());
return false;
}
const auto targetNodeId = targetNodeId_opt.value();
- const auto dm_bl = distmap::gen(db, targetNodeId);
+ const auto dm_bl = distmap::gen(serialized.get(), targetNodeId);
const auto &dm = dm_bl.distmap;
const auto &bl = dm_bl.blacklist;
@@ -738,9 +726,10 @@ bool KleeHandler::buildDistMapAndBlackList
// }
for (const auto &M : loadedModules) {
- build_distmap_blacklist_for_module(dm, bl, distMap, blackList, *M);
+ build_distmap_blacklist_for_module(dm, bl, distMap, blackList, facts, *M);
}
- build_distmap_blacklist_for_module(dm, bl, distMap, blackList, *mainModule);
+ build_distmap_blacklist_for_module(dm, bl, distMap, blackList, facts,
+ *mainModule);
// std::cout << "distMap.size() = " << distMap.size() << std::endl
// << "blackList.size() = " << blackList.size() << std::endl;
diff --git a/resolve-cc/src/ResolveFactsPluginPass.cpp b/resolve-cc/src/ResolveFactsPluginPass.cpp
index f0ddfea60..773c12a7f 100644
--- a/resolve-cc/src/ResolveFactsPluginPass.cpp
+++ b/resolve-cc/src/ResolveFactsPluginPass.cpp
@@ -3,7 +3,7 @@
* LGPL-3; See LICENSE.txt in the repo root for details.
*/
-#include "resolve_facts_llvm/resolve_facts_llvm.hpp"
+#include "resolve_facts_llvm/binary_facts_llvm.hpp"
#include "llvm/IR/Module.h"
#include "llvm/IR/PassManager.h"
@@ -12,8 +12,10 @@
struct ResolveFactsPluginPass : public PassInfoMixin {
PreservedAnalyses run(Module &M, ModuleAnalysisManager &) {
- resolve::getModuleFacts(M);
- resolve::embedFacts(M);
+ resolve::BinaryLLVMFacts facts;
+ resolve::getBinaryModuleFacts(facts, M);
+ const auto serialized = facts.serialize();
+ resolve::embedBinaryFacts(M, serialized.bytes());
return PreservedAnalyses::all();
}
};
diff --git a/resolve-cli/CMakeLists.txt b/resolve-cli/CMakeLists.txt
index c4fe57b91..4a7653a9a 100644
--- a/resolve-cli/CMakeLists.txt
+++ b/resolve-cli/CMakeLists.txt
@@ -4,6 +4,48 @@
set(RESOLVE_PYTHON_VERSION "3.12" CACHE STRING "Python version used for the resolve CLI environment")
option(RESOLVE_BUNDLE_PYTHON "Install a uv-managed Python into the resolve install prefix" OFF)
+# Build the reachability command.
+find_program(CARGO_EXECUTABLE cargo REQUIRED)
+
+if(CMAKE_BUILD_TYPE STREQUAL "Debug" OR CMAKE_BUILD_TYPE STREQUAL "")
+ set(REACH_CARGO_PROFILE debug)
+ set(REACH_CARGO_FLAGS)
+else()
+ set(REACH_CARGO_PROFILE release)
+ set(REACH_CARGO_FLAGS --release)
+endif()
+
+set(REACH_CRATE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/src/resolve/reach")
+set(REACH_TARGET_DIR "${CMAKE_CURRENT_BINARY_DIR}/reach-target")
+set(REACH_BINARY "${REACH_TARGET_DIR}/${REACH_CARGO_PROFILE}/resolve-reach")
+set(REACH_CARGO_ENV
+ "CARGO_TARGET_DIR=${REACH_TARGET_DIR}"
+ "RESOLVE_LIBREACH_DIR=$"
+)
+
+add_custom_target(resolve-reach ALL
+ COMMAND ${CMAKE_COMMAND} -E env
+ ${REACH_CARGO_ENV}
+ ${CARGO_EXECUTABLE} build --locked ${REACH_CARGO_FLAGS}
+ WORKING_DIRECTORY "${REACH_CRATE_DIR}"
+ BYPRODUCTS "${REACH_BINARY}"
+ DEPENDS libreach
+ COMMENT "Building resolve-reach"
+ USES_TERMINAL
+ VERBATIM
+)
+
+add_custom_target(test-resolve-reach
+ COMMAND ${CMAKE_COMMAND} -E env
+ ${REACH_CARGO_ENV}
+ ${CARGO_EXECUTABLE} test --locked
+ WORKING_DIRECTORY "${REACH_CRATE_DIR}"
+ DEPENDS libreach
+ COMMENT "Running the resolve-reach tests"
+ USES_TERMINAL
+ VERBATIM
+)
+
# Make the install prefix a Python environment for the resolve CLI tools.
install(CODE "
set(_resolve_python_version \"${RESOLVE_PYTHON_VERSION}\")
@@ -93,3 +135,22 @@ install(CODE "
message(FATAL_ERROR \"uv pip install failed with exit code \${_uv_result}\")
endif()
")
+
+# Install the Rust command after the Python package. This replaces the legacy
+# Python entry point during an in-place upgrade.
+install(CODE "
+ set(_install_prefix \"\${CMAKE_INSTALL_PREFIX}\")
+ if(DEFINED ENV{DESTDIR} AND NOT \"\$ENV{DESTDIR}\" STREQUAL \"\")
+ if(IS_ABSOLUTE \"\${_install_prefix}\")
+ set(_install_prefix \"\$ENV{DESTDIR}\${_install_prefix}\")
+ else()
+ set(_install_prefix \"\$ENV{DESTDIR}/\${_install_prefix}\")
+ endif()
+ endif()
+
+ file(REMOVE \"\${_install_prefix}/${CMAKE_INSTALL_BINDIR}/resolve-reach\")
+")
+
+install(PROGRAMS "${REACH_BINARY}"
+ DESTINATION "${CMAKE_INSTALL_BINDIR}"
+)
diff --git a/resolve-cli/pyproject.toml b/resolve-cli/pyproject.toml
index b725314c7..934cec1d0 100644
--- a/resolve-cli/pyproject.toml
+++ b/resolve-cli/pyproject.toml
@@ -11,12 +11,10 @@ dependencies = [
"ollama>=0.6.1",
"pydantic>=2.12.5",
"pyelftools>=0.32",
- "univers>=31.1.0",
]
[project.scripts]
resolve = "resolve.cli:main"
-resolve-reach = "resolve.reach:main"
resolve-remediate = "resolve.remediate:main"
resolve-get-facts = "resolve.get_facts:main"
resolve-crash-analysis = "resolve.crash_analyzer.smith:main"
diff --git a/resolve-cli/src/resolve/cli.py b/resolve-cli/src/resolve/cli.py
index 908d606c5..a7a299c99 100644
--- a/resolve-cli/src/resolve/cli.py
+++ b/resolve-cli/src/resolve/cli.py
@@ -31,7 +31,9 @@ def files_in_path_dirs(path_dirs: list[Path]):
path_dirs.insert(0, argv0_path.resolve().parent)
for file in files_in_path_dirs(path_dirs):
if (sub := is_subcommand(file)) and os.access(file, os.X_OK):
- subcommands[sub] = file
+ # Use the command from the first matching directory. This keeps
+ # the installed command ahead of stale commands later in PATH.
+ subcommands.setdefault(sub, file)
return subcommands
def subcommand_cli(program: str):
diff --git a/resolve-cli/src/resolve/reach.py b/resolve-cli/src/resolve/reach.py
deleted file mode 100644
index 90499e3dd..000000000
--- a/resolve-cli/src/resolve/reach.py
+++ /dev/null
@@ -1,735 +0,0 @@
-#!/usr/bin/env python3
-#
-# Copyright (c) 2025 Riverside Research.
-# LGPL-3; See LICENSE.txt in the repo root for details.
-
-from dataclasses import dataclass, field
-
-from operator import attrgetter
-import os
-import gc
-import json
-import argparse
-import subprocess
-from pathlib import Path
-from enum import Enum, auto
-from typing import Any, Callable, Iterable, TypeVar
-
-from univers.version_range import GenericVersionRange
-from univers.versions import SemverVersion
-
-class Reachability(Enum):
- UNKNOWN = auto()
-
- UNREACHABLE_NOT_FOUND = auto()
- UNREACHABLE_NO_PATH = auto()
- UNREACHABLE_NOT_VULNERABLE = auto()
-
- REACHABLE = auto()
-
-@dataclass
-class Sink:
- """
- Represent a target fn we would like to reach in a reachability
- query
-
- Preserves the info about a sink from vulnerabilities.json
- so we don't have to blindly assume we map 1-to-1 with an output
- reach query, since not every sink is reachable
- """
-
- # supplied by vulnerabilities.json
- cve_id: str
- cve_description: str
- package_name: str
- vulnerable_package_version: str
- package_version: str | None
- cwe_id: str
- cwe_name: str
- affected_function: str
- affected_file: str
-
- @classmethod
- def from_vuln_dict(cls, vuln: dict[str, str]) -> "Sink":
- """
- Load metadata from TA2 supplied vulnerabilities.json
- """
-
- def get(key: str):
- val = vuln.get(key, None)
- if val is not None:
- return val
- return vuln[key.replace("-", "_")]
-
- return cls(
- # The only required fields for our analysis
- cve_id=get("cve-id"),
- affected_function=get("affected-function"),
- # Misc
- cve_description=get("cve-description"),
- package_name=get("package-name"),
- vulnerable_package_version=get("package-version"),
- package_version=None, # populate later if we get the src dir
- cwe_id=get("cwe-id"),
- cwe_name=get("cwe-name"),
- affected_file=get("affected-file"),
- )
-
-T = TypeVar("T")
-K = TypeVar("K")
-def group_by(items: Iterable[T], key_func: Callable[[T], K]):
- result: dict[K, list[T]] = {}
- for item in items:
- key = key_func(item)
- result.setdefault(key, []).append(item)
-
- return result
-
-NodeID = tuple[int, int]
-EdgeID = tuple[NodeID, NodeID]
-NodeKind = str
-EdgeKind = str
-
-@dataclass
-class Node:
- id: NodeID
- kind: NodeKind
- props: dict[str, Any] = field(default_factory=dict[str, Any])
-
- T = TypeVar('T')
- def get(self, key: str, default: T = None) -> str | T:
- return self.props.get(key, default)
-
- def __getitem__(self, key: str):
- return self.props[key]
-
- def __setitem__(self, key: str, value: Any):
- self.props[key] = value
-
- def get_name(self):
- if demangled_name := self.get("demangled_name", None):
- return demangled_name
-
- if name := self.get("name", None):
- return name
-
- if idx := self.get("idx", None):
- return idx
-
- return ""
-
- def __str__(self):
- return f"{self.kind}({self.get_name()}) ({self.id})"
-
-@dataclass
-class Nodes:
- def __init__(self, nodes: Iterable[Node] = []):
- self.ids = {node.id: node for node in nodes}
- self.kinds = group_by(nodes, attrgetter("kind"))
-
- ids: dict[NodeID, Node]
- kinds: dict[NodeKind, list[Node]]
-
- def __getitem__(self, id: NodeID):
- return self.ids[id]
-
- def __iter__(self):
- return self.ids.values().__iter__()
-
-@dataclass
-class Edge:
- id: EdgeID
- src: NodeID
- dst: NodeID
- kinds: list[EdgeKind]
- props: dict[str, Any] = field(default_factory=dict[str, Any])
-
- T = TypeVar('T')
- def get(self, key: str, default: T = None) -> str | T:
- return self.props.get(key, default)
-
- def __getitem__(self, key: str):
- return self.props[key]
-
- def __setitem__(self, key: str, value: Any):
- self.props[key] = value
-
-@dataclass
-class Edges:
- def __init__(self, edges: Iterable[Edge] = []):
- self.ids = {edge.id: edge for edge in edges}
- self.kinds = {}
- for e in edges:
- for k in e.kinds:
- self.kinds.setdefault(k, []).append(e)
-
- self.srcs = group_by(edges, attrgetter("src"))
- self.dsts = group_by(edges, attrgetter("dst"))
-
- ids: dict[EdgeID, Edge]
- kinds: dict[EdgeKind, list[Edge]]
- srcs: dict[NodeID, list[Edge]]
- dsts: dict[NodeID, list[Edge]]
-
- def __getitem__(self, id: EdgeID):
- return self.ids[id]
-
- def __iter__(self):
- return self.ids.values().__iter__()
-
-def demangle(names: Iterable[str]):
- name_input = "\n".join(names)
- res = subprocess.run(["c++filt"], input=name_input, stdout=subprocess.PIPE, text=True)
- if res.returncode:
- print(f"[RW]: ERROR: c++filt tool exited with code {res.returncode}")
- print("[RW]: c++filt STDOUT:", res.stdout)
- print("[RW]: c++filt STDERR:", res.stderr)
- return names
-
- return res.stdout.split("\n")
-
-class FactParser:
- def __init__(self, facts_file: Path):
- self.facts_file = facts_file
-
- # def deserialize_edge(k: str, e: dict[str, Any]):
- # # glaze serializes pairs as {first:second}
- # d = json.loads(k)
- # src, dst = d
-
- # sid = (mid, int(src))
- # did = (mid, int(dst))
- # return Edge((sid, did), sid, did, e["kinds"], {})
-
- # Load Nodes
- with (facts_file).open() as f:
- all_nodes: list[Node] = []
- # all_edges: list[Edge] = []
- # When multiple modules are combined they will be separated by newlines.
- for line in f:
- facts = json.loads(line.strip('\n'))
-
- for mid, m in facts["modules"].items():
- mid = int(mid)
- all_nodes += [
- Node((mid, int(id)), n["type"], n)
- for id, n in m["nodes"].items()
- ]
-
- # all_edges += [deserialize_edge(k, e) for k, e in m["edges"].items()]
-
- self.nodes = Nodes(all_nodes)
- # self.edges = Edges(all_edges)
-
- def demangle_names(self):
- with_names = [n for n in self.nodes if "name" in n.props]
- demangled = demangle([n["name"] for n in with_names])
- for n, demangled_name in zip(with_names, demangled):
- if n["name"] != demangled_name:
- n["demangled_name"] = demangled_name
-
- def get_node_module_name(self, id: NodeID):
- module_id = (id[0], id[0])
- name = self.nodes[module_id].props["source_file"]
- return name
-
- def get_func_id(self, func_name: str, file_name: str = ""):
- matches: list[NodeID] = []
- # First try true symbol names
- for f in self.nodes.kinds["Function"]:
- # try to get the function that we are precisely looking for
- if func_name != f.get("name", ""):
- continue
- # Function.source_file is based on debug info, which may or may not be populated, but is more specific in the case of i.e. header files
- if file_name in f.get(
- "source_file", ""
- ) or file_name in self.get_node_module_name(f.id):
- return f.id
-
- # Next try demangled C++ symbol names
- for f in self.nodes.kinds["Function"]:
- # If we fail to get an exact match, try a substring match on demangled names
- if func_name in f.props.get(
- "demangled_name", ""
- ) and file_name in self.get_node_module_name(f.id):
- matches.append(f.id)
-
- match len(matches):
- case 0:
- return None
- case 1:
- pass
- case _:
- print(
- f"[RW]: WARNING: multiple metadata matches for {file_name}:{func_name}"
- )
-
- return matches[0]
-
-@dataclass
-class ReachToolResult:
- nodes: list[Node] = field(default_factory=list[Node])
- edges: list[str] = field(default_factory=list[str])
-
- def _as_cfg_path(self):
- nodes = iter(self.nodes)
- edges = iter(self.edges)
- try:
- yield str(next(nodes))
- while True:
- edge = next(edges)
- node = next(nodes)
- yield f"{edge} -> {node}"
- except StopIteration:
- return
-
- def as_cfg_path(self):
- return list(self._as_cfg_path())
-
- def _as_call_path(self):
- nodes = iter(self.nodes)
- edges = iter(self.edges)
- try:
- yield str(next(nodes))
- while True:
- edge = next(edges)
- node = next(nodes)
- if edge in ["Succ", "Contains"]:
- continue
- yield f"{edge} -> {node}"
- except StopIteration:
- return
-
- def as_call_path(self):
- return list(self._as_call_path())
-
- def _as_edges(self):
- nodes = iter(self.nodes)
- edges = iter(self.edges)
- try:
- source = next(nodes)
- while True:
- edge = next(edges)
- destination = next(nodes)
- yield (source, edge, destination)
- source = destination
- except StopIteration:
- return
-
- def as_edges(self):
- return list(self._as_edges())
-
-# A mapping from dst_id to path list
-ReachToolResults = dict[NodeID, list[ReachToolResult]]
-
-@dataclass
-class ReachabilityResult:
- sink: Sink
- reachability: Reachability = Reachability.UNKNOWN
-
- # found in facts.facts
- func_id: NodeID | None = None
-
- # from reach tool
- paths: list[ReachToolResult] | None = None
-
- def update_from_fact_parser(self, fact_parser: 'FactParser') -> None:
- """
- Looks for the function signature of a sink in nodeprops.facts
- and updates it with its func_id
- """
- func_id = fact_parser.get_func_id(
- self.sink.affected_function, self.sink.affected_file
- )
- if func_id is None:
- self.reachability = Reachability.UNREACHABLE_NOT_FOUND
- return
-
- file_name = fact_parser.get_node_module_name(func_id)
- print(f"Found function '{self.sink.affected_function}' in module '{file_name}'")
-
- self.func_id = func_id
-
- def update_from_tool_results(self, tool_results: ReachToolResults):
- assert self.func_id is not None
- try:
- self.paths = tool_results[self.func_id]
- except KeyError:
- return
-
- self.reachability = Reachability.REACHABLE if len(self.paths) else Reachability.UNREACHABLE_NO_PATH
-
- def get_dict(self) -> dict[str, Any]:
- """
- Gets a dict of what we expect each sink to appear as
- in our output json to TA2
- """
- match self.reachability:
- case Reachability.UNREACHABLE_NOT_FOUND:
- classification = "unreachable"
- justification = {
- "conclusion": "Not Found",
- "reason": f"The affected function {self.sink.affected_file}:{self.sink.affected_function} was not found in compiled program metadata.",
- }
- case Reachability.UNREACHABLE_NO_PATH:
- classification = "unreachable"
- justification = {
- "conclusion": "Not Reachable",
- "reason": f"Control Flow Graph analysis found no paths to target function {self.sink.affected_file}:{self.sink.affected_function}.",
- }
- case Reachability.REACHABLE:
- assert self.paths
- classification = "potentially reachable"
- justification: dict[str, str | list[str]] = {
- "conclusion": "Statically Reachable",
- "reason": "Control Flow Graph analysis found the following candidate path...",
- "call_path": self.paths[0].as_call_path(),
- "control_flow_path": self.paths[0].as_cfg_path()
- }
- case Reachability.UNREACHABLE_NOT_VULNERABLE:
- classification = "unreachable"
- justification = {
- "conclusion": "Not Vulnerable",
- "reason": "The package version is not considered vulnerable according to the supplied version information. It may or may not still be reachable."
- }
- case other:
- print(f"[RW]: ERROR: Unexpected `reach` status \"{other}\"")
- classification = "Unable to assess"
- justification = {
- "conclusion": "Error: internal tool failure"
- }
-
- return {
- "cve_id": self.sink.cve_id,
- "classification": classification,
- "justification": justification
- }
-
-class ReachToolManager:
- def __init__(self, facts_file: Path, src_id: str, tmp_reach_input_path: Path, reach_output_path: Path, reach_path: Path, reach_args: list[str]):
- self.facts_file = facts_file
- self.src_id = src_id
- self.reach_output_path = reach_output_path
- self.reach_path = reach_path
- self.reach_args = reach_args
-
- self.tmp_reach_input_path = tmp_reach_input_path
- self.tmp_reach_input_path.parent.mkdir(parents=True, exist_ok=True) # probably redundant
-
- def get_tool_input(self, results: list[ReachabilityResult]) -> dict[str, Any]:
- return {
- "cache": False,
- "queries": [
- {"src": self.src_id, "dst": result.func_id} for result in results
- ]
- }
-
- def serialize_tool_input(self, results: list[ReachabilityResult]) -> None:
- input = self.get_tool_input(results)
-
- with self.tmp_reach_input_path.open("w") as f:
- json.dump(input, f, indent=4)
- print(f"[RW]: Wrote {self.tmp_reach_input_path}")
-
- def invoke_reach(self) -> None:
- cmd = [
- str(self.reach_path),
- "-f", str(self.facts_file),
- "-i", str(self.tmp_reach_input_path),
- "-o", str(self.reach_output_path)
- ]
- cmd.extend(self.reach_args)
- print(f"[RW]: Invoking reach '{' '.join(cmd)}'")
- res = subprocess.run(cmd, capture_output=True, text=True)
-
- if(res.returncode != 0):
- print(f"[RW]: ERROR: reach tool exited with code {res.returncode}")
- print("[RW]: reach STDOUT:", res.stdout)
- print("[RW]: reach STDERR:", res.stderr)
- else:
- print(f"[RW]: reach wrote output to {self.reach_output_path}")
-
- def get_tool_results(self, fact_parser: FactParser) -> ReachToolResults:
- with open(self.reach_output_path, "r") as rf:
- reach_file = json.load(rf)
- print(f"[RW]: Read {self.reach_output_path}")
-
- # Convert list of KV pairs to map
- def parse_result_path(nodes: list[list[int]], edges: list[str]):
- return ReachToolResult(nodes=[fact_parser.nodes[tuple(id)] for id in nodes], edges=edges)
-
- return {
- tuple(result["dst"]): [parse_result_path(**r) for r in result["paths"]] for result in reach_file["query_results"]
- }
-
-class Orchestrator:
- def __init__(self, facts_file: str, vuln_json_path: str, final_out_path: str, reach_bin_path: str|None, reach_args: list[str], cp_src_dir: str|None, graph_dir: str|None, entrypoint: str):
- DEFAULT_REACH_PATH = "reach"
-
- self.reach_args = reach_args
- self.facts_file = Path(facts_file)
- self.vuln_json_path = Path(vuln_json_path)
- self.final_out_path = (
- Path(final_out_path)
- if final_out_path
- else Path(vuln_json_path).with_suffix(".reach.json")
- )
- self.reach_bin_path = Path(reach_bin_path) if reach_bin_path else DEFAULT_REACH_PATH
- self.cp_src_dir = Path(cp_src_dir) if cp_src_dir else None
-
- self.fact_parser = FactParser(self.facts_file)
- self.fact_parser.demangle_names()
-
- self.output_graph_path = graph_dir
-
- self.entrypoint = entrypoint
-
- # Load vulnerabilities.json
- with open(self.vuln_json_path, "r") as vj:
- vuln_json = json.load(vj)
-
- # Initialize results
- sinks = [Sink.from_vuln_dict(vuln) for vuln in vuln_json["vulnerabilities"]]
- self.results = [ReachabilityResult(sink) for sink in sinks]
-
- def parse_vulnerable_results(self):
- # If we have a source code directory, populate the package version
- if self.cp_src_dir is None:
- print("[RW]: WARNING: No source code directory provided, package versions will not be populated.")
- return
- cp_src_dir = self.cp_src_dir
-
- def is_vulnerable(vuln_version: str, actual_version: str) -> bool:
- vrange = GenericVersionRange.from_string(f"vers:generic/{vuln_version}")
- vstr = SemverVersion(actual_version)
- return vrange.contains(vstr)
-
- def get_version(package_name: str):
- # First, check for overlay ports
- vcpkg_json = cp_src_dir / "vcpkg-overlays/ports" / package_name / "vcpkg.json"
-
- # If no port, try root
- if not vcpkg_json.exists():
- vcpkg_json = cp_src_dir / "vcpkg.json"
-
- with vcpkg_json.open("r") as f:
- vcpkg_data = json.load(f)
-
- # Check that the name matches
- if vcpkg_data.get("name", None) != package_name:
- return None, vcpkg_json
-
- # get the version from the vcpkg.json
- return vcpkg_data.get("version", None), vcpkg_json
-
- # for sink in sinks:
- for result in self.results:
- name = result.sink.package_name
- vulnerable_version_string = result.sink.vulnerable_package_version
-
- vcpkg_version_string, vcpkg_json = get_version(name)
- if vcpkg_version_string is None:
- print(f"[RW]: WARNING: Could not find vcpkg.json for package '{name}' in {self.cp_src_dir / 'vcpkg-overlays/ports' / name}")
- continue
-
- print(f"[RW]: Populated package version for '{name}' from {vcpkg_json}: {vcpkg_version_string}")
- result.sink.package_version = vcpkg_version_string
-
- # check if there is a version match
- if is_vulnerable(vulnerable_version_string, vcpkg_version_string):
- print(f"[RW]: Package version '{vcpkg_version_string}' is considered vulnerable according to '{vulnerable_version_string}'")
- else:
- print(f"[RW]: Package version '{vcpkg_version_string}' is not considered vulnerable according to '{vulnerable_version_string}'")
- result.reachability = Reachability.UNREACHABLE_NOT_VULNERABLE
-
- def get_unsolved_results(self):
- return [result for result in self.results if result.reachability == Reachability.UNKNOWN]
-
- def parse_facts(self):
- "Update results from fact_parser to get func_id"
-
- # NOTE: we assume that we can always enter
- # the desired basic block from an 'fmain'
- src = self.fact_parser.get_func_id(self.entrypoint)
- assert src is not None, f"Could not find source function '{self.entrypoint}'"
-
- file_name = self.fact_parser.get_node_module_name(src)
-
- print(f"Found function '{self.entrypoint}' in module '{file_name}'")
- self.src = src
- for result in self.results:
- result.update_from_fact_parser(self.fact_parser)
-
- def run_reach_tool(self):
- "Run reach tool and update Results"
- # TODO (optional): implement flags to specify the intermediate file placements
-
- """
- https://stackoverflow.com/a/48710609
- """
- src = self.src
- def is_docker():
- def text_in_file(text: str, filename: str):
- try:
- with open(filename, encoding='utf-8') as lines:
- return any(text in line for line in lines)
- except OSError:
- return False
- cgroup = '/proc/self/cgroup'
- return os.path.exists('/.dockerenv') or text_in_file('docker', cgroup)
-
- if os.getenv("CI") is not None or is_docker():
- tmp_in = Path("/tmp/reach_wrap_input.json")
- tmp_out = Path("/tmp/reach_wrap_output.json")
- else:
- tmp_in = Path("reach_wrap_input.json")
- tmp_out = Path("reach_wrap_output.json")
-
- self.input_manager = ReachToolManager(self.facts_file, src, tmp_in, tmp_out, self.reach_bin_path, self.reach_args)
-
- unsolved_results = self.get_unsolved_results()
- self.input_manager.serialize_tool_input(unsolved_results)
-
- #
- # HACK: On problems with large facts, reach wrapper uses
- # a truly ridiculous amount of memory. In order to
- # prevent the EBOSS CI runners from OOM-ing, we
- # destroy FactParser before invoking reach, and
- # then pay the cost of re-building it twice after
- # reach exits.
- #
- self.fact_parser = None
- gc.collect()
- self.input_manager.invoke_reach()
- self.fact_parser = FactParser(self.facts_file)
- self.fact_parser.demangle_names()
-
- tool_results = self.input_manager.get_tool_results(fact_parser=self.fact_parser)
- for result in unsolved_results:
- result.update_from_tool_results(tool_results)
-
- def serialize_output(self):
- "Print results as final output"
- data = {
- "reachability_results": [result.get_dict() for result in self.results]
- }
- self.final_out_path.parent.mkdir(parents=True, exist_ok=True) # probably redundant
- with self.final_out_path.open("w") as f:
- json.dump(data, f, indent=4)
- print(f"[RW]: Wrote {self.final_out_path}.")
-
- def serialize_as_graph(self):
- if not self.output_graph_path:
- return
-
- os.makedirs(self.output_graph_path, exist_ok=True)
-
- # sink nodes
- with open(Path(self.output_graph_path, "nodeprops.facts"), "w") as nodeprops_file:
- for result in self.results:
- if result.reachability is Reachability.UNREACHABLE_NOT_FOUND:
- continue
-
- nodeprops_file.write(f"{result.func_id},\"vulnerability_id\",{result.sink.cve_id}\n")
- nodeprops_file.write(f"{result.func_id},\"reachable\",{ True if result.reachability == Reachability.REACHABLE else False}\n")
-
- # Edges
- with (
- open(Path(self.output_graph_path, "edges.facts"), "w") as edges_file,
- open(Path(self.output_graph_path, "edgeprops.facts"), "w") as edgeprops_file,
- ):
- i = 0
- for result in self.results:
- if result.reachability is not Reachability.REACHABLE:
- continue
-
- assert result.paths is not None
- for path in result.paths:
- for source, edge, destination in path.as_edges():
- edges_file.write(f"{i},{'ReachablePath'},{source.id},{destination.id}\n")
- edgeprops_file.write(f"{i},{'kind'},{edge}\n")
- i+=1
-
- def main(self):
- self.parse_vulnerable_results()
- self.parse_facts()
-
- self.run_reach_tool()
- self.serialize_output()
- self.serialize_as_graph()
-
-def main():
- parser = argparse.ArgumentParser(
- description="Reach tool wrapper used to manipulate inputs and outputs to desired forms"
- )
-
- parser.add_argument(
- "-i",
- "--input",
- type=str,
- help="the vulnerabilities.json path",
- required=True
- )
-
- parser.add_argument(
- "-o", "--output", type=str, help="the path to write the output file to"
- )
-
- parser.add_argument(
- "-f",
- "--facts",
- type=str,
- help="the file containing the facts",
- required=True
- )
-
- parser.add_argument(
- "-r",
- "--reach",
- type=str,
- help="the path to the reach binary",
- default=None
- )
-
- parser.add_argument(
- "-s",
- "--src",
- type=str,
- help="the folder containing the source code for the cp, it should have a vcpkg-overlays folder",
- default=None,
- )
-
- parser.add_argument(
- "-a",
- "--args",
- type=str,
- help="additional arguments passed verbatim to `reach`",
- nargs=argparse.REMAINDER,
- default=[]
- )
-
- parser.add_argument(
- "-g",
- "--graph",
- type=str,
- help="output a facts file in shared volume that can be imported into neo4j, this argument specifies the path",
- default=None,
- required=False
- )
-
- parser.add_argument(
- "-e",
- "--entry",
- type=str,
- help="The function to use as the entrypoint for reachability analysis. Defaults to `main`",
- default="main",
- required=False
- )
-
- args = parser.parse_args()
- # reach_out = Path("/tmp/reach_out.json")
-
- Orchestrator(args.facts, args.input, args.output, args.reach, args.args, args.src, args.graph, args.entry).main()
-
-if __name__ == "__main__":
- main()
diff --git a/resolve-cli/src/resolve/reach/Cargo.lock b/resolve-cli/src/resolve/reach/Cargo.lock
new file mode 100644
index 000000000..742f4aae8
--- /dev/null
+++ b/resolve-cli/src/resolve/reach/Cargo.lock
@@ -0,0 +1,683 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "cc"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clap"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "convert_case"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "convert_case",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.119",
+ "unicode-xid",
+]
+
+[[package]]
+name = "facts-rs"
+version = "0.1.0"
+dependencies = [
+ "bytemuck",
+ "object",
+ "zstd",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "gloo-utils"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e"
+dependencies = [
+ "js-sys",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jobserver"
+version = "0.1.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
+dependencies = [
+ "getrandom",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "object"
+version = "0.39.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "resolve-reach"
+version = "0.1.0"
+dependencies = [
+ "clap",
+ "facts-rs",
+ "serde",
+ "serde_json",
+ "vers-rs",
+]
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-wasm-bindgen"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e"
+dependencies = [
+ "js-sys",
+ "serde",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_derive_internals"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e578a843d40b4189a4d66bba51d7684f57da5bd7c304c64e14bd63efbef49509"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "tsify"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6b26cf145f2f3b9ff84e182c448eaf05468e247f148cf3d2a7d67d78ff023a0"
+dependencies = [
+ "gloo-utils",
+ "serde",
+ "serde_json",
+ "tsify-macros",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "tsify-macros"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a94b0f0954b3e59bfc2c246b4c8574390d94a4ad4ad246aaf2fb07d7dfd3b47"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde_derive_internals",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "vers-rs"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6143511ab2bfe590aa7231a98fbb124d47462ee0cac70eabae56ee0ec6ba951"
+dependencies = [
+ "derive_more",
+ "js-sys",
+ "percent-encoding",
+ "semver",
+ "serde",
+ "serde-wasm-bindgen",
+ "thiserror",
+ "tsify",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+
+[[package]]
+name = "zstd"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.16+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
diff --git a/resolve-cli/src/resolve/reach/Cargo.toml b/resolve-cli/src/resolve/reach/Cargo.toml
new file mode 100644
index 000000000..3158dcabb
--- /dev/null
+++ b/resolve-cli/src/resolve/reach/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "resolve-reach"
+version = "0.1.0"
+edition = "2024"
+build = "build.rs"
+
+[dependencies]
+clap = { version = "4.6.6", features = ["derive"] }
+facts-rs = { path = "../../../../resolve-facts/rs" }
+serde = { version = "1.0.228", features = ["derive"] }
+serde_json = "1.0.151"
+vers-rs = "0.1.2"
diff --git a/resolve-cli/src/resolve/reach/build.rs b/resolve-cli/src/resolve/reach/build.rs
new file mode 100644
index 000000000..7bab10d7b
--- /dev/null
+++ b/resolve-cli/src/resolve/reach/build.rs
@@ -0,0 +1,34 @@
+use std::{env, path::PathBuf};
+
+fn main() {
+ println!("cargo:rerun-if-env-changed=RESOLVE_LIBREACH_DIR");
+
+ let library_dir = PathBuf::from(
+ env::var_os("RESOLVE_LIBREACH_DIR").expect(
+ "RESOLVE_LIBREACH_DIR is not set; build through the CMake resolve-reach target or set it to the native library directory",
+ ),
+ );
+
+ for library in ["libreach.a", "libresolve_facts.a"] {
+ let path = library_dir.join(library);
+ if !path.is_file() {
+ panic!("required native library does not exist: {}", path.display());
+ }
+ }
+
+ println!(
+ "cargo:rerun-if-changed={}",
+ library_dir.join("libreach.a").display()
+ );
+ println!(
+ "cargo:rerun-if-changed={}",
+ library_dir.join("libresolve_facts.a").display()
+ );
+ println!("cargo:rustc-link-search=native={}", library_dir.display());
+ println!("cargo:rustc-link-lib=static=reach");
+ println!("cargo:rustc-link-lib=static=resolve_facts");
+ println!("cargo:rustc-link-lib=dylib=stdc++");
+ println!("cargo:rustc-link-lib=dylib=pthread");
+ println!("cargo:rustc-link-lib=dylib=dl");
+ println!("cargo:rustc-link-lib=dylib=m");
+}
diff --git a/resolve-cli/src/resolve/reach/src/analysis.rs b/resolve-cli/src/resolve/reach/src/analysis.rs
new file mode 100644
index 000000000..6c1ebae2f
--- /dev/null
+++ b/resolve-cli/src/resolve/reach/src/analysis.rs
@@ -0,0 +1,60 @@
+use facts_rs::FactsBuf;
+
+use crate::{
+ functions::FunctionIndex,
+ libreach::{Graph, GraphBuildOptions},
+ vulnerability::{ReachabilityStatus, VulnerabilityAnalysis},
+};
+
+pub fn populate_reachability_results(
+ analyses: &mut [VulnerabilityAnalysis],
+ facts: &FactsBuf,
+ functions: &FunctionIndex,
+ entry: &str,
+ graph_options: &GraphBuildOptions<'_>,
+) -> Result<(), String> {
+ let entry_id = functions
+ .find(entry, "")
+ .ok_or_else(|| format!("entry function '{entry}' was not found in the facts"))?;
+
+ for analysis in analyses.iter_mut() {
+ analysis.function_id = functions.find(
+ &analysis.vuln.affected_function,
+ &analysis.vuln.affected_file,
+ );
+
+ if analysis.function_id.is_none() && analysis.reachability == ReachabilityStatus::Unknown {
+ analysis.reachability = ReachabilityStatus::NotFound;
+ }
+ }
+
+ if !analyses
+ .iter()
+ .any(|analysis| analysis.reachability == ReachabilityStatus::Unknown)
+ {
+ return Ok(());
+ }
+
+ let graph = Graph::build_with_options(facts, graph_options)?;
+ println!(
+ "[REACH] Built a libreach graph with {} edges.",
+ graph.edge_count()
+ );
+
+ for analysis in analyses
+ .iter_mut()
+ .filter(|analysis| analysis.reachability == ReachabilityStatus::Unknown)
+ {
+ let destination = analysis
+ .function_id
+ .ok_or_else(|| "an unresolved analysis has no function ID".to_owned())?;
+ analysis.paths = graph.query(entry_id, destination, 1)?;
+ analysis.reachability = if analysis.paths.is_empty() {
+ ReachabilityStatus::NoPath
+ } else {
+ ReachabilityStatus::Reachable
+ };
+ }
+
+ Ok(())
+}
diff --git a/resolve-cli/src/resolve/reach/src/functions.rs b/resolve-cli/src/resolve/reach/src/functions.rs
new file mode 100644
index 000000000..44159928a
--- /dev/null
+++ b/resolve-cli/src/resolve/reach/src/functions.rs
@@ -0,0 +1,157 @@
+use std::{
+ io::Write,
+ process::{Command, Stdio},
+};
+
+use facts_rs::{FactsBuf, NodeType};
+
+use crate::libreach::ReachNodeID;
+
+#[derive(Debug)]
+struct Function {
+ id: ReachNodeID,
+ symbol: String,
+ demangled: String,
+ source_file: String,
+ module_file: String,
+}
+
+#[derive(Debug)]
+pub struct FunctionIndex {
+ functions: Vec,
+}
+
+impl FunctionIndex {
+ pub fn build(facts: &FactsBuf) -> Result {
+ let mut functions = Vec::new();
+
+ for (module_index, module) in facts.view().modules().enumerate() {
+ let module = module
+ .map_err(|error| format!("failed to read facts module {module_index}: {error}"))?;
+ let module_id = u32::try_from(module_index)
+ .map_err(|_| "facts contain too many modules".to_owned())?;
+ let module_file = module
+ .node_ref(0)
+ .and_then(|node| node.source_file())
+ .unwrap_or_default()
+ .to_owned();
+
+ for node in module.node_refs() {
+ if node.node_type() != Ok(NodeType::Function) {
+ continue;
+ }
+ let Some(symbol) = node.name() else {
+ continue;
+ };
+
+ functions.push(Function {
+ id: ReachNodeID {
+ module: module_id,
+ node: node.id(),
+ },
+ symbol: symbol.to_owned(),
+ demangled: String::new(),
+ source_file: node.source_file().unwrap_or_default().to_owned(),
+ module_file: module_file.clone(),
+ });
+ }
+ }
+
+ let symbols = functions
+ .iter()
+ .map(|function| function.symbol.as_str())
+ .collect::>();
+ let demangled = demangle(&symbols)?;
+ for (function, demangled) in functions.iter_mut().zip(demangled) {
+ function.demangled = demangled;
+ }
+
+ Ok(Self { functions })
+ }
+
+ pub fn find(&self, name: &str, file: &str) -> Option {
+ if let Some(function) = self
+ .functions
+ .iter()
+ .find(|function| function.symbol == name && function.matches_file(file))
+ {
+ return Some(function.id);
+ }
+
+ let matches = self
+ .functions
+ .iter()
+ .filter(|function| function.demangled.contains(name) && function.matches_file(file))
+ .collect::>();
+
+ if matches.len() > 1 {
+ println!(
+ "[REACH] WARNING: Multiple functions match '{}:{}'. Using '{}'.",
+ file, name, matches[0].demangled
+ );
+ }
+
+ matches.first().map(|function| function.id)
+ }
+
+ pub fn display_name(&self, id: ReachNodeID) -> Option<&str> {
+ self.functions
+ .iter()
+ .find(|function| function.id == id)
+ .map(|function| function.demangled.as_str())
+ }
+}
+
+impl Function {
+ fn matches_file(&self, file: &str) -> bool {
+ file.is_empty() || self.source_file.contains(file) || self.module_file.contains(file)
+ }
+}
+
+fn demangle(symbols: &[&str]) -> Result, String> {
+ if symbols.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let input = format!("{}\n", symbols.join("\n"));
+ let mut child = Command::new("c++filt")
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .map_err(|error| format!("failed to start c++filt: {error}"))?;
+ let mut stdin = child
+ .stdin
+ .take()
+ .ok_or_else(|| "failed to open c++filt input".to_owned())?;
+ let writer = std::thread::spawn(move || stdin.write_all(input.as_bytes()));
+ let output = child
+ .wait_with_output()
+ .map_err(|error| format!("failed to wait for c++filt: {error}"))?;
+
+ let write_result = writer
+ .join()
+ .map_err(|_| "c++filt input writer panicked".to_owned())?;
+ if !output.status.success() {
+ return Err(format!(
+ "c++filt failed: {}",
+ String::from_utf8_lossy(&output.stderr).trim()
+ ));
+ }
+ write_result.map_err(|error| format!("failed to write to c++filt: {error}"))?;
+
+ let demangled = String::from_utf8(output.stdout)
+ .map_err(|error| format!("c++filt returned invalid UTF-8: {error}"))?
+ .lines()
+ .map(str::to_owned)
+ .collect::>();
+ if demangled.len() != symbols.len() {
+ return Err(format!(
+ "c++filt returned {} names for {} symbols",
+ demangled.len(),
+ symbols.len()
+ ));
+ }
+
+ Ok(demangled)
+}
diff --git a/resolve-cli/src/resolve/reach/src/libreach.rs b/resolve-cli/src/resolve/reach/src/libreach.rs
new file mode 100644
index 000000000..b3bfe3128
--- /dev/null
+++ b/resolve-cli/src/resolve/reach/src/libreach.rs
@@ -0,0 +1,326 @@
+use std::{ffi::c_void, ptr::NonNull, slice};
+
+use facts_rs::{FactsBuf, NodeID};
+use serde::Deserialize;
+
+#[repr(C)]
+struct ReachGraph {
+ _private: [u8; 0],
+}
+
+#[repr(C)]
+struct ReachQueryResult {
+ _private: [u8; 0],
+}
+
+#[repr(C)]
+struct ReachError {
+ _private: [u8; 0],
+}
+
+#[repr(C)]
+struct ReachStringView {
+ data: *const u8,
+ len: usize,
+}
+
+#[repr(C)]
+struct ReachLoadedSymbol {
+ symbol: ReachStringView,
+ library: ReachStringView,
+}
+
+#[repr(C)]
+struct ReachBuildOptions {
+ loaded_symbols: *const ReachLoadedSymbol,
+ loaded_symbol_count: usize,
+ dynlink: u8,
+ filter_loaded_symbols: u8,
+}
+
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+#[repr(C)]
+pub struct ReachNodeID {
+ pub module: u32,
+ pub node: NodeID,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ReachEdgeType {
+ DirectCall,
+ IndirectCall,
+ Contains,
+ Successor,
+ External,
+ ExternalIndirectCall,
+}
+
+impl ReachEdgeType {
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::DirectCall => "DirectCall",
+ Self::IndirectCall => "IndirectCall",
+ Self::Contains => "Contains",
+ Self::Successor => "Succ",
+ Self::External => "Extern",
+ Self::ExternalIndirectCall => "ExternIndirectCall",
+ }
+ }
+}
+
+impl TryFrom for ReachEdgeType {
+ type Error = String;
+
+ fn try_from(value: u8) -> Result {
+ match value {
+ 0 => Ok(Self::DirectCall),
+ 1 => Ok(Self::IndirectCall),
+ 2 => Ok(Self::Contains),
+ 3 => Ok(Self::Successor),
+ 4 => Ok(Self::External),
+ 5 => Ok(Self::ExternalIndirectCall),
+ _ => Err(format!("libreach returned unknown edge type {value}")),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ReachPath {
+ pub nodes: Vec,
+ pub edges: Vec,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct LoadedSymbol {
+ pub symbol: String,
+ pub library: String,
+}
+
+#[derive(Debug, Default)]
+pub struct GraphBuildOptions<'a> {
+ pub loaded_symbols: Option<&'a [LoadedSymbol]>,
+ pub dynlink: bool,
+}
+
+#[repr(C)]
+struct ReachPathView {
+ nodes: *const ReachNodeID,
+ node_count: usize,
+ edges: *const u8,
+ edge_count: usize,
+}
+
+unsafe extern "C" {
+ fn reach_graph_build(
+ facts: *const c_void,
+ options: *const ReachBuildOptions,
+ error: *mut *mut ReachError,
+ ) -> *mut ReachGraph;
+ fn reach_graph_free(graph: *mut ReachGraph);
+ fn reach_graph_edge_count(graph: *const ReachGraph) -> usize;
+
+ fn reach_graph_query(
+ graph: *const ReachGraph,
+ src: ReachNodeID,
+ dst: ReachNodeID,
+ max_paths: usize,
+ error: *mut *mut ReachError,
+ ) -> *mut ReachQueryResult;
+ fn reach_query_result_free(result: *mut ReachQueryResult);
+ fn reach_query_result_path_count(result: *const ReachQueryResult) -> usize;
+ fn reach_query_result_path(
+ result: *const ReachQueryResult,
+ index: usize,
+ path: *mut ReachPathView,
+ ) -> u8;
+
+ fn reach_error_data(error: *const ReachError) -> *const u8;
+ fn reach_error_len(error: *const ReachError) -> usize;
+ fn reach_error_free(error: *mut ReachError);
+}
+
+pub struct Graph {
+ raw: NonNull,
+}
+
+impl Graph {
+ pub fn build_with_options(
+ facts: &FactsBuf,
+ options: &GraphBuildOptions<'_>,
+ ) -> Result {
+ let loaded_symbols = options
+ .loaded_symbols
+ .unwrap_or_default()
+ .iter()
+ .map(|symbol| ReachLoadedSymbol {
+ symbol: ReachStringView::new(&symbol.symbol),
+ library: ReachStringView::new(&symbol.library),
+ })
+ .collect::>();
+ let ffi_options = ReachBuildOptions {
+ loaded_symbols: if loaded_symbols.is_empty() {
+ std::ptr::null()
+ } else {
+ loaded_symbols.as_ptr()
+ },
+ loaded_symbol_count: loaded_symbols.len(),
+ dynlink: u8::from(options.dynlink),
+ filter_loaded_symbols: u8::from(options.loaded_symbols.is_some()),
+ };
+ let mut error = std::ptr::null_mut();
+ let graph = unsafe {
+ reach_graph_build(
+ std::ptr::from_ref(facts).cast(),
+ std::ptr::from_ref(&ffi_options),
+ &mut error,
+ )
+ };
+
+ match NonNull::new(graph) {
+ Some(raw) => Ok(Self { raw }),
+ None => Err(unsafe { take_error(error, "libreach could not build the graph") }),
+ }
+ }
+
+ pub fn edge_count(&self) -> usize {
+ unsafe { reach_graph_edge_count(self.raw.as_ptr()) }
+ }
+
+ pub fn query(
+ &self,
+ source: ReachNodeID,
+ destination: ReachNodeID,
+ max_paths: usize,
+ ) -> Result, String> {
+ let mut error = std::ptr::null_mut();
+ let result = unsafe {
+ reach_graph_query(
+ self.raw.as_ptr(),
+ source,
+ destination,
+ max_paths,
+ &mut error,
+ )
+ };
+ let result = NonNull::new(result)
+ .ok_or_else(|| unsafe { take_error(error, "libreach could not complete the query") })?;
+ let result = QueryResult { raw: result };
+
+ result.paths()
+ }
+}
+
+impl ReachStringView {
+ fn new(value: &str) -> Self {
+ Self {
+ data: value.as_ptr(),
+ len: value.len(),
+ }
+ }
+}
+
+impl Drop for Graph {
+ fn drop(&mut self) {
+ unsafe { reach_graph_free(self.raw.as_ptr()) };
+ }
+}
+
+struct QueryResult {
+ raw: NonNull,
+}
+
+impl QueryResult {
+ fn paths(&self) -> Result, String> {
+ let path_count = unsafe { reach_query_result_path_count(self.raw.as_ptr()) };
+ let mut paths = Vec::with_capacity(path_count);
+
+ for index in 0..path_count {
+ let mut view = ReachPathView {
+ nodes: std::ptr::null(),
+ node_count: 0,
+ edges: std::ptr::null(),
+ edge_count: 0,
+ };
+ let found = unsafe { reach_query_result_path(self.raw.as_ptr(), index, &mut view) };
+ if found == 0 {
+ return Err(format!("libreach did not return path {index}"));
+ }
+
+ let nodes = unsafe { slice_from_raw_parts(view.nodes, view.node_count) }.to_vec();
+ let edges = unsafe { slice_from_raw_parts(view.edges, view.edge_count) }
+ .iter()
+ .copied()
+ .map(ReachEdgeType::try_from)
+ .collect::, _>>()?;
+ paths.push(ReachPath { nodes, edges });
+ }
+
+ Ok(paths)
+ }
+}
+
+impl Drop for QueryResult {
+ fn drop(&mut self) {
+ unsafe { reach_query_result_free(self.raw.as_ptr()) };
+ }
+}
+
+unsafe fn slice_from_raw_parts<'a, T>(data: *const T, len: usize) -> &'a [T] {
+ if len == 0 {
+ &[]
+ } else {
+ unsafe { slice::from_raw_parts(data, len) }
+ }
+}
+
+unsafe fn take_error(error: *mut ReachError, fallback: &str) -> String {
+ let Some(error) = NonNull::new(error) else {
+ return fallback.to_owned();
+ };
+ let length = unsafe { reach_error_len(error.as_ptr()) };
+ let data = unsafe { reach_error_data(error.as_ptr()) };
+ let message = if data.is_null() {
+ fallback.to_owned()
+ } else {
+ String::from_utf8_lossy(unsafe { slice::from_raw_parts(data, length) }).into_owned()
+ };
+ unsafe { reach_error_free(error.as_ptr()) };
+ message
+}
+
+#[cfg(test)]
+mod tests {
+ use facts_rs::{EdgeKind, FactsBuilder, NodeType};
+
+ use super::{Graph, GraphBuildOptions, ReachEdgeType, ReachNodeID};
+
+ #[test]
+ fn builds_and_queries_a_graph() {
+ let mut builder = FactsBuilder::new();
+ let module = builder.add_module(3);
+ assert_eq!(builder.add_node(module, NodeType::Module), Some(0));
+ let function = builder.add_node(module, NodeType::Function).unwrap();
+ let block = builder.add_node(module, NodeType::BasicBlock).unwrap();
+ assert!(builder.add_edge(module, function, block, EdgeKind::EntryPoint));
+
+ let graph =
+ Graph::build_with_options(&builder.freeze(), &GraphBuildOptions::default()).unwrap();
+ let paths = graph
+ .query(
+ ReachNodeID {
+ module,
+ node: function,
+ },
+ ReachNodeID {
+ module,
+ node: block,
+ },
+ 1,
+ )
+ .unwrap();
+
+ assert_eq!(graph.edge_count(), 1);
+ assert_eq!(paths.len(), 1);
+ assert_eq!(paths[0].edges, vec![ReachEdgeType::Contains]);
+ }
+}
diff --git a/resolve-cli/src/resolve/reach/src/main.rs b/resolve-cli/src/resolve/reach/src/main.rs
new file mode 100644
index 000000000..13905e20b
--- /dev/null
+++ b/resolve-cli/src/resolve/reach/src/main.rs
@@ -0,0 +1,180 @@
+use std::{
+ fs,
+ path::{Path, PathBuf},
+};
+
+use clap::{ArgAction, Parser};
+use facts_rs::FactsBuf;
+use serde::Deserialize;
+
+use analysis::populate_reachability_results;
+use functions::FunctionIndex;
+use libreach::{GraphBuildOptions, LoadedSymbol};
+use serializer::write_report;
+use vcpkg::populate_version_results;
+use vulnerability::{VulnerabilityAnalysis, VulnerabilityJSON};
+
+mod analysis;
+mod functions;
+mod libreach;
+mod serializer;
+mod vcpkg;
+mod vulnerability;
+
+#[derive(Parser, Debug)]
+#[command(
+ name = "resolve-reach",
+ about = "Analyze static reachability for known vulnerabilities"
+)]
+struct Args {
+ /// Input vulnerabilities.json
+ #[arg(short, long)]
+ input: PathBuf,
+
+ /// Files or directories containing facts (ELF, .so, .facts)
+ #[arg(short, long, required = true, num_args=1, action= ArgAction::Append)]
+ facts: Vec,
+
+ /// The file to write the final report into
+ #[arg(short, long)]
+ output: Option,
+
+ /// Source tree containing vcpkg-overlays
+ #[arg(short, long)]
+ src: Option,
+
+ // TODO: C++ WORKER ARGS HERE FOR OTHER SETTINGS
+ /// Entry function to traverse to vulnerable sink from
+ #[arg(short, long, default_value = "main")]
+ entry: String,
+
+ /// Include external-linkage functions as indirect-call targets
+ #[arg(long)]
+ dynlink: bool,
+
+ /// JSON log of symbols loaded through dlsym
+ #[arg(long)]
+ dlsym_log: Option,
+}
+
+#[derive(Deserialize)]
+struct DlsymLog {
+ loaded_symbols: Vec,
+}
+
+fn load_vuln_json(path: &Path) -> Result {
+ let contents =
+ fs::read(path).map_err(|error| format!("failed to read '{}': {error}", path.display()))?;
+
+ serde_json::from_slice(&contents)
+ .map_err(|error| format!("failed to parse '{}': {error}", path.display()))
+}
+
+fn expand_facts_paths(paths: &[PathBuf]) -> Result, String> {
+ let mut files = Vec::new();
+
+ for path in paths {
+ if !path.is_dir() {
+ files.push(path.clone());
+ continue;
+ }
+
+ let mut directory_files = fs::read_dir(path)
+ .map_err(|error| format!("failed to read '{}': {error}", path.display()))?
+ .map(|entry| {
+ entry
+ .map(|entry| entry.path())
+ .map_err(|error| format!("failed to read '{}': {error}", path.display()))
+ })
+ .collect::, _>>()?;
+ directory_files.retain(|file| {
+ file.is_file()
+ && file
+ .extension()
+ .is_some_and(|extension| extension == "facts")
+ });
+ directory_files.sort();
+
+ if directory_files.is_empty() {
+ return Err(format!(
+ "facts directory '{}' contains no .facts files",
+ path.display()
+ ));
+ }
+ files.extend(directory_files);
+ }
+
+ Ok(files)
+}
+
+fn load_facts(paths: &[PathBuf]) -> Result<(FactsBuf, usize), String> {
+ let files = expand_facts_paths(paths)?;
+ let count = files.len();
+ let facts =
+ FactsBuf::read_files(&files).map_err(|error| format!("failed to load facts: {error}"))?;
+
+ Ok((facts, count))
+}
+
+fn load_dlsym_log(path: &Path) -> Result, String> {
+ let contents =
+ fs::read(path).map_err(|error| format!("failed to read '{}': {error}", path.display()))?;
+ let log: DlsymLog = serde_json::from_slice(&contents)
+ .map_err(|error| format!("failed to parse '{}': {error}", path.display()))?;
+
+ Ok(log.loaded_symbols)
+}
+
+fn run() -> Result<(), String> {
+ let args = Args::parse();
+ let input = load_vuln_json(&args.input)?;
+ let output = args
+ .output
+ .unwrap_or_else(|| args.input.with_extension("reach.json"));
+ let mut analyses: Vec =
+ input.vulnerabilities.into_iter().map(Into::into).collect();
+
+ if let Some(src_dir) = args.src.as_deref() {
+ populate_version_results(&mut analyses, src_dir)?;
+ } else {
+ println!(
+ "[REACH] WARNING: No source code directory provided, package versions will not be populated."
+ );
+ }
+
+ let (facts, facts_file_count) = load_facts(&args.facts)?;
+ let module_count = facts
+ .view()
+ .modules()
+ .try_fold(0usize, |count, module| module.map(|_| count + 1))
+ .map_err(|error| format!("failed to iterate over facts modules: {error}"))?;
+
+ println!(
+ "[REACH] Loaded {module_count} facts modules from {} input files.",
+ facts_file_count
+ );
+
+ let functions = FunctionIndex::build(&facts)?;
+ let loaded_symbols = args.dlsym_log.as_deref().map(load_dlsym_log).transpose()?;
+ let graph_options = GraphBuildOptions {
+ loaded_symbols: loaded_symbols.as_deref(),
+ dynlink: args.dynlink,
+ };
+ populate_reachability_results(
+ &mut analyses,
+ &facts,
+ &functions,
+ &args.entry,
+ &graph_options,
+ )?;
+ write_report(&output, &analyses, &facts, &functions)?;
+
+ Ok(())
+}
+
+fn main() {
+ if let Err(error) = run() {
+ eprintln!("error: {error}");
+ std::process::exit(1);
+ }
+}
diff --git a/resolve-cli/src/resolve/reach/src/serializer.rs b/resolve-cli/src/resolve/reach/src/serializer.rs
new file mode 100644
index 000000000..17facf7e6
--- /dev/null
+++ b/resolve-cli/src/resolve/reach/src/serializer.rs
@@ -0,0 +1,227 @@
+use std::{
+ fs::{self, File},
+ io::{BufWriter, Write},
+ path::Path,
+};
+
+use facts_rs::FactsBuf;
+use serde::Serialize;
+
+use crate::{
+ functions::FunctionIndex,
+ libreach::{ReachEdgeType, ReachNodeID, ReachPath},
+ vulnerability::{ReachabilityStatus, VulnerabilityAnalysis},
+};
+
+#[derive(Serialize)]
+struct ReachabilityReport {
+ reachability_results: Vec,
+}
+
+#[derive(Serialize)]
+struct ReportResult {
+ cve_id: String,
+ classification: &'static str,
+ justification: Justification,
+}
+
+#[derive(Serialize)]
+struct Justification {
+ conclusion: &'static str,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ reason: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ call_path: Option>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ control_flow_path: Option>,
+}
+
+pub fn write_report(
+ path: &Path,
+ analyses: &[VulnerabilityAnalysis],
+ facts: &FactsBuf,
+ functions: &FunctionIndex,
+) -> Result<(), String> {
+ let report = build_report(analyses, facts, functions)?;
+ if let Some(parent) = path
+ .parent()
+ .filter(|parent| !parent.as_os_str().is_empty())
+ {
+ fs::create_dir_all(parent)
+ .map_err(|error| format!("failed to create '{}': {error}", parent.display()))?;
+ }
+
+ let file = File::create(path)
+ .map_err(|error| format!("failed to create '{}': {error}", path.display()))?;
+ let mut writer = BufWriter::new(file);
+ serde_json::to_writer_pretty(&mut writer, &report)
+ .map_err(|error| format!("failed to serialize '{}': {error}", path.display()))?;
+ writer
+ .write_all(b"\n")
+ .map_err(|error| format!("failed to write '{}': {error}", path.display()))?;
+ writer
+ .flush()
+ .map_err(|error| format!("failed to write '{}': {error}", path.display()))?;
+
+ println!("[REACH] Wrote '{}'.", path.display());
+ Ok(())
+}
+
+fn build_report(
+ analyses: &[VulnerabilityAnalysis],
+ facts: &FactsBuf,
+ functions: &FunctionIndex,
+) -> Result {
+ let reachability_results = analyses
+ .iter()
+ .map(|analysis| build_result(analysis, facts, functions))
+ .collect::, _>>()?;
+
+ Ok(ReachabilityReport {
+ reachability_results,
+ })
+}
+
+fn build_result(
+ analysis: &VulnerabilityAnalysis,
+ facts: &FactsBuf,
+ functions: &FunctionIndex,
+) -> Result {
+ let target = format!(
+ "{}:{}",
+ analysis.vuln.affected_file, analysis.vuln.affected_function
+ );
+
+ let (classification, justification) = match analysis.reachability {
+ ReachabilityStatus::NotFound => (
+ "unreachable",
+ Justification::new(
+ "Not Found",
+ format!(
+ "The affected function {target} was not found in compiled program metadata."
+ ),
+ ),
+ ),
+ ReachabilityStatus::NoPath => (
+ "unreachable",
+ Justification::new(
+ "Not Reachable",
+ format!(
+ "Control Flow Graph analysis found no paths to target function {target}."
+ ),
+ ),
+ ),
+ ReachabilityStatus::NotVulnerable => (
+ "unreachable",
+ Justification::new(
+ "Not Vulnerable",
+ "The package version is not considered vulnerable according to the supplied version information. It may or may not still be reachable."
+ .to_owned(),
+ ),
+ ),
+ ReachabilityStatus::Reachable => {
+ let path = analysis
+ .paths
+ .first()
+ .ok_or_else(|| format!("reachable result '{}' has no path", analysis.vuln.cve_id))?;
+ let (call_path, control_flow_path) = format_path(path, facts, functions)?;
+ (
+ "potentially reachable",
+ Justification {
+ conclusion: "Statically Reachable",
+ reason: Some(
+ "Control Flow Graph analysis found the following candidate path..."
+ .to_owned(),
+ ),
+ call_path: Some(call_path),
+ control_flow_path: Some(control_flow_path),
+ },
+ )
+ }
+ ReachabilityStatus::Unknown => (
+ "Unable to assess",
+ Justification {
+ conclusion: "Error: internal tool failure",
+ reason: None,
+ call_path: None,
+ control_flow_path: None,
+ },
+ ),
+ };
+
+ Ok(ReportResult {
+ cve_id: analysis.vuln.cve_id.clone(),
+ classification,
+ justification,
+ })
+}
+
+fn format_path(
+ path: &ReachPath,
+ facts: &FactsBuf,
+ functions: &FunctionIndex,
+) -> Result<(Vec, Vec), String> {
+ if path.nodes.len() != path.edges.len() + 1 {
+ return Err("libreach returned a path with mismatched nodes and edges".to_owned());
+ }
+
+ let nodes = path
+ .nodes
+ .iter()
+ .copied()
+ .map(|id| format_node(id, facts, functions))
+ .collect::, _>>()?;
+ let mut call_path = vec![nodes[0].clone()];
+ let mut control_flow_path = vec![nodes[0].clone()];
+
+ for (edge, formatted_node) in path.edges.iter().zip(nodes.into_iter().skip(1)) {
+ let step = format!("{} -> {formatted_node}", edge.as_str());
+ control_flow_path.push(step.clone());
+ if !matches!(edge, ReachEdgeType::Contains | ReachEdgeType::Successor) {
+ call_path.push(step);
+ }
+ }
+
+ Ok((call_path, control_flow_path))
+}
+
+fn format_node(
+ id: ReachNodeID,
+ facts: &FactsBuf,
+ functions: &FunctionIndex,
+) -> Result {
+ let module = facts
+ .view()
+ .modules()
+ .nth(id.module as usize)
+ .ok_or_else(|| format!("facts do not contain module {}", id.module))?
+ .map_err(|error| format!("failed to read facts module {}: {error}", id.module))?;
+ let node = module.node_ref(id.node).ok_or_else(|| {
+ format!(
+ "facts module {} does not contain node {}",
+ id.module, id.node
+ )
+ })?;
+ let kind = node
+ .node_type()
+ .map_err(|value| format!("facts node ({}, {}) has type {value}", id.module, id.node))?;
+ let name = functions
+ .display_name(id)
+ .map(str::to_owned)
+ .or_else(|| node.name().map(str::to_owned))
+ .or_else(|| node.idx().map(|index| index.to_string()))
+ .unwrap_or_default();
+
+ Ok(format!("{kind:?}({name}) (({}, {}))", id.module, id.node))
+}
+
+impl Justification {
+ fn new(conclusion: &'static str, reason: String) -> Self {
+ Self {
+ conclusion,
+ reason: Some(reason),
+ call_path: None,
+ control_flow_path: None,
+ }
+ }
+}
diff --git a/resolve-cli/src/resolve/reach/src/vcpkg.rs b/resolve-cli/src/resolve/reach/src/vcpkg.rs
new file mode 100644
index 000000000..16bef7c56
--- /dev/null
+++ b/resolve-cli/src/resolve/reach/src/vcpkg.rs
@@ -0,0 +1,191 @@
+use std::{
+ fs::File,
+ path::{Path, PathBuf},
+};
+
+use serde::Deserialize;
+use vers_rs::GenericVersionRange;
+use vers_rs::range::VersionRange;
+use vers_rs::schemes::semver::SemVer;
+
+use crate::vulnerability::{ReachabilityStatus, VulnerabilityAnalysis};
+
+#[derive(Debug, Deserialize)]
+struct VcpkgManifest {
+ name: Option,
+ version: Option,
+ #[serde(rename = "version-semver")]
+ version_semver: Option,
+ #[serde(rename = "version-string")]
+ version_string: Option,
+ #[serde(rename = "version-date")]
+ version_date: Option,
+}
+
+impl VcpkgManifest {
+ fn into_version(self) -> Option {
+ self.version
+ .or(self.version_semver)
+ .or(self.version_string)
+ .or(self.version_date)
+ }
+}
+
+fn normalize_semver(version: &str) -> String {
+ let version = version.trim();
+ let suffix_start = version.find(['-', '+']).unwrap_or(version.len());
+ let (core, suffix) = version.split_at(suffix_start);
+ let components: Vec<&str> = core.split('.').collect();
+
+ if !components.iter().all(|component| {
+ !component.is_empty()
+ && component
+ .chars()
+ .all(|character| character.is_ascii_digit())
+ }) {
+ return version.to_owned();
+ }
+
+ match components.len() {
+ 1 => format!("{core}.0.0{suffix}"),
+ 2 => format!("{core}.0{suffix}"),
+ _ => version.to_owned(),
+ }
+}
+
+fn normalize_constraint(constraint: &str) -> String {
+ let constraint = constraint.trim();
+ let Some(version_start) = constraint.find(|character: char| character.is_ascii_digit()) else {
+ return constraint.to_owned();
+ };
+ let (operator, version) = constraint.split_at(version_start);
+
+ format!("{operator}{}", normalize_semver(version))
+}
+
+fn normalize_range(vuln_range: &str) -> String {
+ let (prefix, constraints) = match vuln_range.strip_prefix("vers:") {
+ Some(range) => match range.split_once('/') {
+ Some((scheme, constraints)) => (format!("vers:{scheme}/"), constraints),
+ None => ("vers:generic/".to_owned(), vuln_range),
+ },
+ None => ("vers:generic/".to_owned(), vuln_range),
+ };
+ let constraints = constraints
+ .split('|')
+ .map(normalize_constraint)
+ .collect::>()
+ .join("|");
+
+ format!("{prefix}{constraints}")
+}
+
+fn is_vulnerable(vuln_range: &str, actual_version: &str) -> Result {
+ let range_spec = normalize_range(vuln_range);
+ let range = range_spec
+ .parse::>()
+ .map_err(|error| format!("failed to parse version range '{vuln_range}': {error}"))?;
+ let normalized_version = normalize_semver(actual_version);
+ let version = normalized_version
+ .parse::()
+ .map_err(|error| format!("failed to parse package version '{actual_version}': {error}"))?;
+
+ range
+ .contains(&version)
+ .map_err(|error| format!("failed to compare package versions: {error}"))
+}
+
+fn get_version(src_dir: &Path, package_name: &str) -> Result<(Option, PathBuf), String> {
+ let overlay_manifest = src_dir
+ .join("vcpkg-overlays")
+ .join("ports")
+ .join(package_name)
+ .join("vcpkg.json");
+ let manifest_path = if overlay_manifest.is_file() {
+ overlay_manifest
+ } else {
+ src_dir.join("vcpkg.json")
+ };
+
+ let manifest_file = File::open(&manifest_path)
+ .map_err(|error| format!("failed to read '{}': {error}", manifest_path.display()))?;
+ let manifest: VcpkgManifest = serde_json::from_reader(manifest_file)
+ .map_err(|error| format!("failed to parse '{}': {error}", manifest_path.display()))?;
+
+ if manifest.name.as_deref() != Some(package_name) {
+ return Ok((None, manifest_path));
+ }
+
+ Ok((manifest.into_version(), manifest_path))
+}
+
+pub fn populate_version_results(
+ sinks: &mut [VulnerabilityAnalysis],
+ src_dir: &Path,
+) -> Result<(), String> {
+ for sink in sinks {
+ let (actual_version, manifest_path) = get_version(src_dir, &sink.vuln.package_name)?;
+ let Some(actual_version) = actual_version else {
+ println!(
+ "[REACH] WARNING: Could not find a matching vcpkg package in '{}'.",
+ manifest_path.display()
+ );
+ continue;
+ };
+
+ println!(
+ "[REACH] Populated package version for '{}' from '{}': {}",
+ sink.vuln.package_name,
+ manifest_path.display(),
+ actual_version
+ );
+
+ match is_vulnerable(&sink.vuln.package_version, &actual_version) {
+ Ok(true) => println!(
+ "[REACH] Package version '{}' is vulnerable according to '{}'.",
+ actual_version, sink.vuln.package_version
+ ),
+ Ok(false) => {
+ sink.reachability = ReachabilityStatus::NotVulnerable;
+ println!(
+ "[REACH] Package version '{}' is not vulnerable according to '{}'.",
+ actual_version, sink.vuln.package_version
+ );
+ }
+ Err(error) => println!(
+ "[REACH] WARNING: Could not compare the package version for '{}': {error}. Reachability analysis will continue.",
+ sink.vuln.package_name
+ ),
+ }
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{is_vulnerable, normalize_range, normalize_semver};
+
+ #[test]
+ fn normalizes_short_numeric_versions() {
+ assert_eq!(normalize_semver("0"), "0.0.0");
+ assert_eq!(normalize_semver("2.21"), "2.21.0");
+ assert_eq!(normalize_semver("2.21-beta.1"), "2.21.0-beta.1");
+ assert_eq!(normalize_semver("7.10.3"), "7.10.3");
+ }
+
+ #[test]
+ fn normalizes_each_range_constraint() {
+ assert_eq!(
+ normalize_range(">= 2.20|<3"),
+ "vers:generic/>= 2.20.0|<3.0.0"
+ );
+ assert_eq!(normalize_range("vers:generic/2.21"), "vers:generic/2.21.0");
+ }
+
+ #[test]
+ fn compares_short_versions() {
+ assert!(is_vulnerable("0", "0").unwrap());
+ assert!(is_vulnerable("2.21", "2.21").unwrap());
+ }
+}
diff --git a/resolve-cli/src/resolve/reach/src/vulnerability.rs b/resolve-cli/src/resolve/reach/src/vulnerability.rs
new file mode 100644
index 000000000..750f84d98
--- /dev/null
+++ b/resolve-cli/src/resolve/reach/src/vulnerability.rs
@@ -0,0 +1,54 @@
+use serde::Deserialize;
+
+use crate::libreach::{ReachNodeID, ReachPath};
+
+/// vulnerabilities.json
+#[derive(Debug, Deserialize)]
+pub struct VulnerabilityJSON {
+ pub vulnerabilities: Vec,
+}
+
+/// A vulnerability inside vulnerabilities.json
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct Vulnerability {
+ #[serde(alias = "cve_id")]
+ pub cve_id: String,
+ #[serde(alias = "package_name")]
+ pub package_name: String,
+ #[serde(alias = "package_version")]
+ pub package_version: String,
+ #[serde(alias = "affected_function")]
+ pub affected_function: String,
+ #[serde(alias = "affected_file")]
+ pub affected_file: String,
+}
+
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum ReachabilityStatus {
+ #[default]
+ Unknown,
+ NotFound,
+ NoPath,
+ NotVulnerable,
+ Reachable,
+}
+
+#[derive(Debug)]
+pub struct VulnerabilityAnalysis {
+ pub vuln: Vulnerability,
+ pub reachability: ReachabilityStatus,
+ pub function_id: Option,
+ pub paths: Vec,
+}
+
+impl From for VulnerabilityAnalysis {
+ fn from(vulnerability: Vulnerability) -> Self {
+ Self {
+ vuln: vulnerability,
+ reachability: ReachabilityStatus::Unknown,
+ function_id: None,
+ paths: Vec::new(),
+ }
+ }
+}
diff --git a/resolve-cli/uv.lock b/resolve-cli/uv.lock
index def3e551c..082561001 100644
--- a/resolve-cli/uv.lock
+++ b/resolve-cli/uv.lock
@@ -636,15 +636,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/4f/4a617ee93d8208d2bcf26b2d8b9402ceaed03e3853c754940e2290fed063/ollama-0.6.1-py3-none-any.whl", hash = "sha256:fc4c984b345735c5486faeee67d8a265214a31cbb828167782dc642ce0a2bf8c", size = 14354, upload-time = "2025-11-13T23:02:16.292Z" },
]
-[[package]]
-name = "packaging"
-version = "26.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
-]
-
[[package]]
name = "propcache"
version = "0.4.1"
@@ -845,6 +836,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
]
+[[package]]
+name = "pyelftools"
+version = "0.32"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b9/ab/33968940b2deb3d92f5b146bc6d4009a5f95d1d06c148ea2f9ee965071af/pyelftools-0.32.tar.gz", hash = "sha256:6de90ee7b8263e740c8715a925382d4099b354f29ac48ea40d840cf7aa14ace5", size = 15047199, upload-time = "2025-02-19T14:20:05.549Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/af/43/700932c4f0638c3421177144a2e86448c0d75dbaee2c7936bda3f9fd0878/pyelftools-0.32-py3-none-any.whl", hash = "sha256:013df952a006db5e138b1edf6d8a68ecc50630adbd0d83a2d41e7f846163d738", size = 188525, upload-time = "2025-02-19T14:19:59.919Z" },
+]
+
[[package]]
name = "requests"
version = "2.33.1"
@@ -860,15 +860,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
]
-[[package]]
-name = "pyelftools"
-version = "0.32"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b9/ab/33968940b2deb3d92f5b146bc6d4009a5f95d1d06c148ea2f9ee965071af/pyelftools-0.32.tar.gz", hash = "sha256:6de90ee7b8263e740c8715a925382d4099b354f29ac48ea40d840cf7aa14ace5", size = 15047199, upload-time = "2025-02-19T14:20:05.549Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/af/43/700932c4f0638c3421177144a2e86448c0d75dbaee2c7936bda3f9fd0878/pyelftools-0.32-py3-none-any.whl", hash = "sha256:013df952a006db5e138b1edf6d8a68ecc50630adbd0d83a2d41e7f846163d738", size = 188525, upload-time = "2025-02-19T14:19:59.919Z" },
-]
-
[[package]]
name = "resolve-cli"
version = "0.1.0"
@@ -880,7 +871,6 @@ dependencies = [
{ name = "ollama" },
{ name = "pydantic" },
{ name = "pyelftools" },
- { name = "univers" },
]
[package.metadata]
@@ -890,27 +880,7 @@ requires-dist = [
{ name = "google-genai", specifier = ">=1.69.0" },
{ name = "ollama", specifier = ">=0.6.1" },
{ name = "pydantic", specifier = ">=2.12.5" },
-
{ name = "pyelftools", specifier = ">=0.32" },
- { name = "univers", specifier = ">=31.1.0" },
-]
-
-[[package]]
-name = "semantic-version"
-version = "2.10.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7d/31/f2289ce78b9b473d582568c234e104d2a342fd658cc288a7553d83bb8595/semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c", size = 52289, upload-time = "2022-05-26T13:35:23.454Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177", size = 15552, upload-time = "2022-05-26T13:35:21.206Z" },
-]
-
-[[package]]
-name = "semver"
-version = "3.0.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" },
]
[[package]]
@@ -952,21 +922,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
-[[package]]
-name = "univers"
-version = "31.1.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "attrs" },
- { name = "packaging" },
- { name = "semantic-version" },
- { name = "semver" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/78/83/7304856c01eadc320147818aa8b53338955d636db66e2bfdddea8941b527/univers-31.1.0.tar.gz", hash = "sha256:5c617edd03657f02ddaa84db0b66a11134aa604fe04b06e7c828483f089c9da6", size = 294626, upload-time = "2025-09-11T13:31:02.265Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f7/f1/80d9c7c72511c3b792b6bd637334e9ae76ddd2a6e5188876c265bc44aefc/univers-31.1.0-py3-none-any.whl", hash = "sha256:e299829882b058d9355e244609739b7c78ab72da5c786bf7f681ffc9db523665", size = 96759, upload-time = "2025-09-11T13:31:00.928Z" },
-]
-
[[package]]
name = "urllib3"
version = "2.6.3"
diff --git a/resolve-facts/CMakeLists.txt b/resolve-facts/CMakeLists.txt
index aa11efe9c..80950c789 100644
--- a/resolve-facts/CMakeLists.txt
+++ b/resolve-facts/CMakeLists.txt
@@ -13,6 +13,44 @@ endif()
include(GNUInstallDirs)
include(CMakePackageConfigHelpers)
+######################################################################
+# RUST FACTS ABI
+
+set(FACTS_RS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/rs")
+set(FACTS_RS_TARGET_DIR "${FACTS_RS_DIR}/target/release")
+set(FACTS_RS_STATICLIB "${FACTS_RS_TARGET_DIR}/libfacts_rs.a")
+set(FACTS_RS_GENERATED_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated")
+set(FACTS_RS_HEADER "${FACTS_RS_GENERATED_DIR}/facts_rs.hpp")
+file(MAKE_DIRECTORY "${FACTS_RS_GENERATED_DIR}")
+
+find_program(CARGO_EXECUTABLE cargo REQUIRED)
+find_program(CBINDGEN_EXECUTABLE cbindgen HINTS "$ENV{HOME}/.cargo/bin" REQUIRED)
+
+file(GLOB FACTS_RS_SOURCES CONFIGURE_DEPENDS "${FACTS_RS_DIR}/src/*.rs")
+add_custom_command(
+ OUTPUT "${FACTS_RS_STATICLIB}" "${FACTS_RS_HEADER}"
+ COMMAND "${CMAKE_COMMAND}" -E make_directory "${FACTS_RS_GENERATED_DIR}"
+ COMMAND "${CARGO_EXECUTABLE}" build --manifest-path "${FACTS_RS_DIR}/Cargo.toml" --release
+ COMMAND "${CBINDGEN_EXECUTABLE}" --config "${FACTS_RS_DIR}/cbindgen.toml"
+ --crate facts-rs --output "${FACTS_RS_HEADER}" "${FACTS_RS_DIR}"
+ DEPENDS ${FACTS_RS_SOURCES} "${FACTS_RS_DIR}/Cargo.toml" "${FACTS_RS_DIR}/Cargo.lock"
+ "${FACTS_RS_DIR}/cbindgen.toml"
+ WORKING_DIRECTORY "${FACTS_RS_DIR}"
+ COMMENT "Building facts-rs and generating its C++ ABI header"
+ VERBATIM
+)
+add_custom_target(facts_rs_build ALL DEPENDS "${FACTS_RS_STATICLIB}" "${FACTS_RS_HEADER}")
+
+add_library(facts_rs STATIC IMPORTED GLOBAL)
+set_target_properties(facts_rs PROPERTIES
+ IMPORTED_LOCATION "${FACTS_RS_STATICLIB}"
+ INTERFACE_INCLUDE_DIRECTORIES "${FACTS_RS_GENERATED_DIR}"
+)
+add_dependencies(facts_rs facts_rs_build)
+
+install(FILES "${FACTS_RS_STATICLIB}" DESTINATION ${CMAKE_INSTALL_LIBDIR})
+install(FILES "${FACTS_RS_HEADER}" DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
+
add_subdirectory(vendor/argparse)
add_subdirectory(vendor/json)
@@ -39,7 +77,7 @@ file(GLOB_RECURSE SRC
add_library(resolve_facts STATIC libs/resolve_facts/resolve_facts.cpp)
target_include_directories(resolve_facts PUBLIC
- "$/include"
+ "$"
"$"
)
target_link_libraries(resolve_facts PRIVATE glaze::glaze)
@@ -70,12 +108,20 @@ file(GLOB_RECURSE SRC
)
# Build Targets
-add_library(resolve_facts_llvm STATIC libs/resolve_facts_llvm/resolve_facts_llvm.cpp)
+add_library(resolve_facts_llvm STATIC
+ libs/resolve_facts_llvm/binary_facts_llvm.cpp
+)
target_include_directories(resolve_facts_llvm SYSTEM PUBLIC ${LLVM_INCLUDE_DIRS})
-target_link_libraries(resolve_facts_llvm PUBLIC resolve_facts)
+find_package(Threads REQUIRED)
+target_link_libraries(resolve_facts_llvm PUBLIC
+ facts_rs
+ Threads::Threads
+ ${CMAKE_DL_LIBS}
+ m
+)
target_include_directories(resolve_facts_llvm PUBLIC
- "$/include"
+ "$"
"$"
)
set_target_properties(resolve_facts_llvm PROPERTIES POSITION_INDEPENDENT_CODE ON)
@@ -95,21 +141,18 @@ install(DIRECTORY include/resolve_facts_llvm DESTINATION ${CMAKE_INSTALL_INCLUDE
######################################################################
# REACH
-# Collect source files for checks
-file(GLOB_RECURSE SRC
- "${CMAKE_CURRENT_SOURCE_DIR}/src/reach/*.cpp"
- "${CMAKE_CURRENT_SOURCE_DIR}/src/reach/*.hpp"
-)
-
file(GLOB_RECURSE LIB
+ "${CMAKE_CURRENT_SOURCE_DIR}/include/reach/*.h"
"${CMAKE_CURRENT_SOURCE_DIR}/include/reach/*.hpp"
"${CMAKE_CURRENT_SOURCE_DIR}/libs/reach/*.cpp"
+ "${CMAKE_CURRENT_SOURCE_DIR}/libs/reach/*.hpp"
)
# reach lib
add_library(libreach
libs/reach/distmap.cpp
libs/reach/facts.cpp
+ libs/reach/ffi.cpp
libs/reach/graph.cpp
libs/reach/search.cpp
libs/reach/util.cpp
@@ -118,24 +161,28 @@ add_library(libreach
set_target_properties(libreach PROPERTIES OUTPUT_NAME "reach")
target_include_directories(libreach PUBLIC
- "$/include"
+ "$"
"$"
)
-target_link_libraries(libreach PUBLIC resolve_facts json)
+target_include_directories(libreach PRIVATE
+ "${CMAKE_CURRENT_SOURCE_DIR}/libs"
+)
+target_link_libraries(libreach PUBLIC
+ resolve_facts
+ facts_rs
+ json
+ Threads::Threads
+ ${CMAKE_DL_LIBS}
+ m
+)
target_compile_features(libreach PUBLIC cxx_std_23)
-# reach executable
-add_executable(reach src/reach/main.cpp)
-
-target_link_libraries(reach PRIVATE libreach json argparse)
-
if(COMMAND resolve_add_check_targets)
resolve_add_check_targets(libreach ${LIB})
- resolve_add_check_targets(reach ${SRC})
endif()
-install(TARGETS reach libreach EXPORT resolve_facts_targets)
+install(TARGETS libreach EXPORT resolve_facts_targets)
install(DIRECTORY include/reach DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
######################################################################
diff --git a/resolve-facts/Config.cmake.in b/resolve-facts/Config.cmake.in
index 6909bc4fb..e0304d245 100644
--- a/resolve-facts/Config.cmake.in
+++ b/resolve-facts/Config.cmake.in
@@ -1,9 +1,17 @@
@PACKAGE_INIT@
-include("${CMAKE_CURRENT_LIST_DIR}/ResolveFactsTargets.cmake")
-
include(CMakeFindDependencyMacro)
find_dependency(glaze)
+find_dependency(Threads)
+
+if(NOT TARGET facts_rs)
+ add_library(facts_rs STATIC IMPORTED)
+ set_target_properties(facts_rs PROPERTIES
+ IMPORTED_LOCATION "${PACKAGE_PREFIX_DIR}/@CMAKE_INSTALL_LIBDIR@/libfacts_rs.a"
+ INTERFACE_INCLUDE_DIRECTORIES "${PACKAGE_PREFIX_DIR}/@CMAKE_INSTALL_INCLUDEDIR@"
+ )
+endif()
+
include("${CMAKE_CURRENT_LIST_DIR}/ResolveFactsTargets.cmake")
check_required_components(ResolveFacts)
diff --git a/resolve-facts/README.md b/resolve-facts/README.md
index bbd34d7e1..04b14d287 100644
--- a/resolve-facts/README.md
+++ b/resolve-facts/README.md
@@ -5,9 +5,13 @@
# Resolve facts
-Tools for creating and querying RESOLVE binary metadata, including the `reach` tool, which provides fast graph reachability for RESOLVE.
+Libraries for creating and querying RESOLVE binary metadata. The `libreach` library provides graph construction and path search for the `resolve reach` command.
**Full documentation:**
- Facts:
-- `reach` tool:
+- Reachability analysis:
+
+## Future Improvements
+
+Strings are interned at an LLVM Module-level. If we were to intern strings across every module together, we would be able to get more space savings, but likely at the expense of more CPU-heavy assembly/decompression. It's also unclear if/how we could compress ELF strings inline.
diff --git a/resolve-facts/include/reach/distmap.hpp b/resolve-facts/include/reach/distmap.hpp
index 109a58552..4ed8ca0bc 100644
--- a/resolve-facts/include/reach/distmap.hpp
+++ b/resolve-facts/include/reach/distmap.hpp
@@ -10,6 +10,7 @@
#include
#include "reach/facts.hpp"
+#include "json/json.hpp"
using NNodeId = resolve_facts::NamespacedNodeId;
@@ -25,4 +26,8 @@ namespace distmap {
distmap_blacklist
gen(const reach_facts::database &db, const NNodeId &dst, bool dynlink = false,
const std::optional> &loaded_syms = {});
-}
+
+distmap_blacklist
+gen(const facts_rs::FactsBuf *facts, const NNodeId &dst, bool dynlink = false,
+ const std::optional> &loaded_syms = {});
+} // namespace distmap
diff --git a/resolve-facts/include/reach/facts.hpp b/resolve-facts/include/reach/facts.hpp
index cab2b7e61..2f406d751 100644
--- a/resolve-facts/include/reach/facts.hpp
+++ b/resolve-facts/include/reach/facts.hpp
@@ -5,14 +5,13 @@
#pragma once
-#include
-#include
+#include
+#include
#include
+#include
#include
#include
-#include "json/json.hpp"
-
#include "resolve_facts/resolve_facts.hpp"
using NamespacedNodeId = resolve_facts::NamespacedNodeId;
@@ -23,6 +22,10 @@ using NodeType = resolve_facts::NodeType;
using Linkage = resolve_facts::Linkage;
using CallType = resolve_facts::CallType;
+namespace facts_rs {
+struct FactsBuf;
+}
+
namespace reach_facts {
enum class LoadOptions : int {
@@ -71,6 +74,10 @@ struct database {
database load(std::istream &facts, LoadOptions options);
database load(const std::filesystem::path &facts_dir, LoadOptions options);
+std::vector
+find_functions_by_name_suffix(const facts_rs::FactsBuf *facts,
+ std::string_view suffix);
+
bool validate(const database &db);
} // namespace reach_facts
@@ -80,26 +87,6 @@ namespace dlsym {
struct loaded_symbol {
std::string symbol;
std::string library;
- bool operator==(const loaded_symbol &rhs) const {
- return symbol == rhs.symbol && library == rhs.library;
- };
};
-struct log {
- std::vector loaded_symbols;
-};
-
-NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(loaded_symbol, symbol, library);
-NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(log, loaded_symbols);
-
-inline std::optional
-load_log_from_file(const std::filesystem::path &path) {
- std::ifstream f(path);
- if (!f.is_open()) {
- return {};
- }
- nlohmann::json j;
- f >> j;
- return j.template get();
-}
} // namespace dlsym
diff --git a/resolve-facts/include/reach/ffi.h b/resolve-facts/include/reach/ffi.h
new file mode 100644
index 000000000..c54a8c624
--- /dev/null
+++ b/resolve-facts/include/reach/ffi.h
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2026 Riverside Research.
+ * LGPL-3; See LICENSE.txt in the repo root for details.
+ */
+
+#pragma once
+
+#include
+#include
+
+#ifdef __cplusplus
+#include "facts_rs.hpp"
+using ReachFactsBuf = facts_rs::FactsBuf;
+extern "C" {
+#else
+typedef struct ReachFactsBuf ReachFactsBuf;
+#endif
+
+typedef struct ReachGraph ReachGraph;
+typedef struct ReachQueryResult ReachQueryResult;
+typedef struct ReachError ReachError;
+
+typedef struct ReachStringView {
+ const uint8_t *data;
+ size_t len;
+} ReachStringView;
+
+typedef struct ReachLoadedSymbol {
+ ReachStringView symbol;
+ ReachStringView library;
+} ReachLoadedSymbol;
+
+typedef struct ReachBuildOptions {
+ const ReachLoadedSymbol *loaded_symbols;
+ size_t loaded_symbol_count;
+ uint8_t dynlink;
+ uint8_t filter_loaded_symbols;
+} ReachBuildOptions;
+
+typedef struct ReachNodeId {
+ uint32_t module;
+ uint32_t node;
+} ReachNodeId;
+
+typedef uint8_t ReachEdgeType;
+enum {
+ REACH_EDGE_DIRECT_CALL = 0,
+ REACH_EDGE_INDIRECT_CALL = 1,
+ REACH_EDGE_CONTAINS = 2,
+ REACH_EDGE_SUCCESSOR = 3,
+ REACH_EDGE_EXTERNAL = 4,
+ REACH_EDGE_EXTERNAL_INDIRECT_CALL = 5,
+};
+
+typedef struct ReachPathView {
+ const ReachNodeId *nodes;
+ size_t node_count;
+ const ReachEdgeType *edges;
+ size_t edge_count;
+} ReachPathView;
+
+// Borrows facts and all option slices only for this call. The returned graph
+// owns only the derived reachability graph and must be freed by the caller.
+ReachGraph *reach_graph_build(const ReachFactsBuf *facts,
+ const ReachBuildOptions *options,
+ ReachError **error);
+void reach_graph_free(ReachGraph *graph);
+size_t reach_graph_edge_count(const ReachGraph *graph);
+
+ReachQueryResult *reach_graph_query(const ReachGraph *graph, ReachNodeId src,
+ ReachNodeId dst, size_t max_paths,
+ ReachError **error);
+void reach_query_result_free(ReachQueryResult *result);
+size_t reach_query_result_path_count(const ReachQueryResult *result);
+// The returned slices borrow result and remain valid until it is freed.
+uint8_t reach_query_result_path(const ReachQueryResult *result, size_t index,
+ ReachPathView *path);
+
+const uint8_t *reach_error_data(const ReachError *error);
+size_t reach_error_len(const ReachError *error);
+void reach_error_free(ReachError *error);
+
+#ifdef __cplusplus
+} // extern "C"
+#endif
diff --git a/resolve-facts/include/reach/graph.hpp b/resolve-facts/include/reach/graph.hpp
index 64ebb9db5..e39606203 100644
--- a/resolve-facts/include/reach/graph.hpp
+++ b/resolve-facts/include/reach/graph.hpp
@@ -13,6 +13,10 @@
#include "reach/facts.hpp"
+namespace facts_rs {
+struct FactsBuf;
+}
+
using NNodeId = resolve_facts::NamespacedNodeId;
namespace graph {
@@ -58,7 +62,11 @@ struct T {
bool wf(const E &g);
T build_from_program_facts(
- const resolve_facts::ProgramFacts &pf, bool dynlink,
+ const resolve_facts::ProgramFacts &facts, bool dynlink,
+ const std::optional> &loaded_syms);
+
+T build_from_program_facts(
+ const facts_rs::FactsBuf *facts, bool dynlink,
const std::optional> &loaded_syms);
constexpr reach_facts::LoadOptions SIMPLE_LOAD_OPTIONS =
@@ -104,6 +112,10 @@ T build_cfg(
T build_instr_cfg(
const reach_facts::database &db, bool dynlink = false,
const std::optional> &loaded_syms = {});
+
+T build_instr_cfg(
+ const facts_rs::FactsBuf *facts, bool dynlink = false,
+ const std::optional> &loaded_syms = {});
} // namespace graph
namespace std {
diff --git a/resolve-facts/include/resolve_facts_llvm/BinaryLLVMFacts.hpp b/resolve-facts/include/resolve_facts_llvm/BinaryLLVMFacts.hpp
new file mode 100644
index 000000000..2a30ffc9d
--- /dev/null
+++ b/resolve-facts/include/resolve_facts_llvm/BinaryLLVMFacts.hpp
@@ -0,0 +1,269 @@
+/*
+ * Copyright (c) 2025 Riverside Research.
+ * LGPL-3; See LICENSE.txt in the repo root for details.
+ */
+
+#ifndef RESOLVE_LLVM_BINARYLLVMFACTS_HPP
+#define RESOLVE_LLVM_BINARYLLVMFACTS_HPP
+
+#include "facts_rs.hpp"
+
+#include "llvm/ADT/ArrayRef.h"
+#include "llvm/ADT/StringRef.h"
+#include "llvm/IR/BasicBlock.h"
+#include "llvm/IR/Constants.h"
+#include "llvm/IR/Function.h"
+#include "llvm/IR/GlobalVariable.h"
+#include "llvm/IR/Instruction.h"
+#include "llvm/IR/Module.h"
+
+#include
+#include
+#include
+#include
+
+namespace resolve {
+
+using BinaryNodeId = facts_rs::NodeID;
+
+// Owns a Rust FactsBuf
+class BinarySerializedFacts {
+ facts_rs::FactsBuf *buf = nullptr;
+
+public:
+ explicit BinarySerializedFacts(facts_rs::FactsBuf *buf) : buf(buf) {}
+ ~BinarySerializedFacts() { facts_rs::facts_buf_free(buf); }
+
+ BinarySerializedFacts(const BinarySerializedFacts &) = delete;
+ BinarySerializedFacts &operator=(const BinarySerializedFacts &) = delete;
+
+ const facts_rs::FactsBuf *get() const { return buf; }
+
+ llvm::ArrayRef bytes() const {
+ return {facts_rs::facts_buf_data(buf), facts_rs::facts_buf_len(buf)};
+ }
+};
+
+// LLVM-specific ID mapping and recording, doesn't own FactsBuf
+class BinaryLLVMFacts {
+ facts_rs::FactsBuilder *facts = facts_rs::facts_builder_new();
+
+ std::unordered_map
+ moduleHandles;
+ std::unordered_map functionIDs;
+ std::unordered_map basicBlockIDs;
+ std::unordered_map argumentIDs;
+ std::unordered_map instructionIDs;
+ std::unordered_map globalVarIDs;
+
+ facts_rs::ModuleHandle recordNewModule(const size_t size_hint) {
+ const auto module = facts_rs::facts_builder_add_module(facts, size_hint);
+ assert(module != facts_rs::INVALID_ID);
+ return module;
+ }
+
+ BinaryNodeId recordNode(const facts_rs::ModuleHandle module,
+ const facts_rs::NodeType type) {
+ const auto node = facts_rs::facts_builder_add_node(facts, module, type);
+ assert(node != facts_rs::INVALID_ID);
+ return node;
+ }
+
+ static void check(const bool success) {
+ assert(success);
+ (void)success;
+ }
+
+ facts_rs::ModuleHandle addModule(const llvm::Module &M) {
+ if (const auto it = moduleHandles.find(&M); it != moduleHandles.end()) {
+ return it->second;
+ }
+
+ const auto module = recordNewModule(2 * M.getInstructionCount());
+ moduleHandles[&M] = module;
+ [[maybe_unused]] const auto moduleNode =
+ recordNode(module, facts_rs::NodeType::Module);
+ assert(moduleNode == 0);
+ return module;
+ }
+
+ template BinaryNodeId nodeId(const N &node) {
+ return addNode(node);
+ }
+
+ template facts_rs::ModuleHandle moduleId(const N &node) {
+ return getModuleId(node);
+ }
+
+public:
+ BinaryLLVMFacts() = default;
+ ~BinaryLLVMFacts() { facts_rs::facts_builder_free(facts); }
+
+ BinaryLLVMFacts(const BinaryLLVMFacts &) = delete;
+ BinaryLLVMFacts &operator=(const BinaryLLVMFacts &) = delete;
+
+ std::pair
+ getId(const llvm::Instruction &instruction) const {
+ const auto module = moduleHandles.find(instruction.getModule());
+ const auto node = instructionIDs.find(&instruction);
+ assert(module != moduleHandles.end());
+ assert(node != instructionIDs.end());
+ return {module->second, node->second};
+ }
+
+ BinaryNodeId addNode(const llvm::Module &M) {
+ addModule(M);
+ return 0;
+ }
+
+ facts_rs::ModuleHandle getModuleId(const llvm::Module &M) {
+ return addModule(M);
+ }
+
+ template facts_rs::ModuleHandle getModuleId(const T &i) {
+ const llvm::Module *module;
+
+ constexpr bool parent_is_module =
+ std::is_same_v;
+ constexpr bool is_argument = std::is_same_v;
+ if constexpr (parent_is_module) {
+ module = i.getParent();
+ } else if constexpr (is_argument) {
+ module = i.getParent()->getParent();
+ } else {
+ module = i.getModule();
+ }
+
+ assert(module);
+ return addModule(*module);
+ }
+
+ template static std::size_t getIndexInParent(const T &item) {
+ const auto &parent = *item.getParent();
+ return std::distance(parent.begin(), item.getIterator());
+ }
+
+ BinaryNodeId addNode(const llvm::GlobalVariable &GV) {
+ if (globalVarIDs.find(&GV) == globalVarIDs.end()) {
+ const auto id =
+ recordNode(getModuleId(GV), facts_rs::NodeType::GlobalVariable);
+ globalVarIDs[&GV] = id;
+ return id;
+ }
+ return globalVarIDs[&GV];
+ }
+
+ BinaryNodeId addNode(const llvm::Function &F) {
+ if (functionIDs.find(&F) == functionIDs.end()) {
+ const auto id = recordNode(getModuleId(F), facts_rs::NodeType::Function);
+ functionIDs[&F] = id;
+ return id;
+ }
+ return functionIDs[&F];
+ }
+
+ BinaryNodeId addNode(const llvm::Argument &A) {
+ if (argumentIDs.find(&A) == argumentIDs.end()) {
+ const auto id = recordNode(getModuleId(A), facts_rs::NodeType::Argument);
+ argumentIDs[&A] = id;
+ return id;
+ }
+ return argumentIDs[&A];
+ }
+
+ BinaryNodeId addNode(const llvm::BasicBlock &BB) {
+ if (basicBlockIDs.find(&BB) == basicBlockIDs.end()) {
+ const auto id =
+ recordNode(getModuleId(BB), facts_rs::NodeType::BasicBlock);
+ basicBlockIDs[&BB] = id;
+ return id;
+ }
+ return basicBlockIDs[&BB];
+ }
+
+ BinaryNodeId addNode(const llvm::Instruction &I) {
+ if (instructionIDs.find(&I) == instructionIDs.end()) {
+ const auto id =
+ recordNode(getModuleId(I), facts_rs::NodeType::Instruction);
+ instructionIDs[&I] = id;
+ return id;
+ }
+ return instructionIDs[&I];
+ }
+
+ template
+ void addEdge(const S &src, const D &dst, const facts_rs::EdgeKind kind) {
+ const auto m1 = getModuleId(src);
+ [[maybe_unused]] const auto m2 = getModuleId(dst);
+ assert(m1 == m2);
+ check(facts_rs::facts_builder_add_edge(facts, m1, addNode(src),
+ addNode(dst), kind));
+ }
+
+ template void setIdx(const N &node, const uint32_t value) {
+ check(facts_rs::facts_builder_set_node_idx(facts, moduleId(node),
+ nodeId(node), value));
+ }
+
+ template
+ void setName(const N &node, const llvm::StringRef value) {
+ check(facts_rs::facts_builder_set_node_name(
+ facts, moduleId(node), nodeId(node),
+ reinterpret_cast(value.data()), value.size()));
+ }
+
+ template
+ void setOpcode(const N &node, const llvm::StringRef value) {
+ check(facts_rs::facts_builder_set_node_opcode(
+ facts, moduleId(node), nodeId(node),
+ reinterpret_cast(value.data()), value.size()));
+ }
+
+ template
+ void setLinkage(const N &node, const facts_rs::Linkage value) {
+ check(facts_rs::facts_builder_set_node_linkage(facts, moduleId(node),
+ nodeId(node), value));
+ }
+
+ template
+ void setCallType(const N &node, const facts_rs::CallType value) {
+ check(facts_rs::facts_builder_set_node_call_type(facts, moduleId(node),
+ nodeId(node), value));
+ }
+
+ template
+ void setSourceLoc(const N &node, const uint32_t line, const uint32_t col) {
+ check(facts_rs::facts_builder_set_node_source_loc(facts, moduleId(node),
+ nodeId(node), line, col));
+ }
+
+ template
+ void setSourceFile(const N &node, const llvm::StringRef value) {
+ check(facts_rs::facts_builder_set_node_source_file(
+ facts, moduleId(node), nodeId(node),
+ reinterpret_cast(value.data()), value.size()));
+ }
+
+ template
+ void setFunctionType(const N &node, const llvm::StringRef value) {
+ check(facts_rs::facts_builder_set_node_function_type(
+ facts, moduleId(node), nodeId(node),
+ reinterpret_cast(value.data()), value.size()));
+ }
+
+ template void setAddressTaken(const N &node) {
+ check(facts_rs::facts_builder_set_node_address_taken(facts, moduleId(node),
+ nodeId(node), true));
+ }
+
+ BinarySerializedFacts serialize() {
+ auto *buf = facts_rs::facts_builder_freeze(facts);
+ facts = nullptr;
+ assert(buf);
+ return BinarySerializedFacts(buf);
+ }
+};
+
+} // namespace resolve
+
+#endif // RESOLVE_LLVM_BINARYLLVMFACTS_HPP
diff --git a/resolve-facts/include/resolve_facts_llvm/LLVMFacts.hpp b/resolve-facts/include/resolve_facts_llvm/LLVMFacts.hpp
deleted file mode 100644
index 3f7750884..000000000
--- a/resolve-facts/include/resolve_facts_llvm/LLVMFacts.hpp
+++ /dev/null
@@ -1,212 +0,0 @@
-/*
- * Copyright (c) 2025 Riverside Research.
- * LGPL-3; See LICENSE.txt in the repo root for details.
- */
-
-#ifndef RESOLVE_LLVM_LLVMFACTS_HPP
-#define RESOLVE_LLVM_LLVMFACTS_HPP
-
-#include "resolve_facts/resolve_facts.hpp"
-
-#include "llvm/IR/BasicBlock.h"
-#include "llvm/IR/Constants.h"
-#include "llvm/IR/Function.h"
-#include "llvm/IR/GlobalVariable.h"
-#include "llvm/IR/Instruction.h"
-#include "llvm/IR/Module.h"
-#include "llvm/Support/FileSystem.h"
-
-#include
-
-using ProgramFacts = resolve_facts::ProgramFacts;
-using ModuleFacts = resolve_facts::ModuleFacts;
-using Node = resolve_facts::Node;
-using NodeId = resolve_facts::NodeId;
-using NodeType = resolve_facts::NodeType;
-using EdgeId = resolve_facts::EdgeId;
-
-class LLVMFacts {
- ProgramFacts &facts;
- NodeId next_node_id = 1;
-
- std::unordered_map moduleIDs;
- std::unordered_map functionIDs;
- std::unordered_map basicBlockIDs;
- std::unordered_map argumentIDs;
- std::unordered_map instructionIDs;
- std::unordered_map globalVarIDs;
-
- void recordNewModule(const NodeId &id, const size_t size_hint) {
- ModuleFacts mf{};
- // Try to avoid reallocations
- mf.nodes.reserve(size_hint);
- mf.edges.reserve(2 * size_hint);
-
- facts.modules[id] = mf;
- }
-
- /// Record a node fact.
- void recordNode(const NodeId &module, const NodeId &id,
- const NodeType &type) {
- Node node{.type = type};
- facts.modules.at(module).nodes.emplace(id, node);
- }
-
- /// Record a node property.
- template
- void recordNodeProp(const NodeId &module, const NodeId &nodeID,
- F &&update_func) {
- auto &mf = facts.modules.at(module);
- update_func(mf.nodes.at(nodeID));
- }
-
- /// Record an edge fact.
- template
- void recordEdge(const NodeId &module, const NodeId &srcID,
- const NodeId &tgtID, F &&update_func) {
- auto pair = EdgeId(srcID, tgtID);
- auto &mf = facts.modules.at(module);
- auto [it, exists] = mf.edges.try_emplace(pair);
- update_func(it->second);
- }
-
-public:
- LLVMFacts(ProgramFacts &facts) : facts(facts) {}
-
- NodeId addNode(const llvm::Module &M) {
- if (moduleIDs.find(&M) == moduleIDs.end()) {
-
- llvm::SmallString<128> src_path = llvm::StringRef(M.getSourceFileName());
- llvm::sys::fs::make_absolute(src_path);
-
- std::string src = (std::string)src_path;
- size_t hash = std::hash{}(src);
- auto id = (NodeId)hash;
-
- // llvm::errs() << "Creating new module: " << id << "\n";
-
- moduleIDs[&M] = id;
-
- // Estimate how many total nodes we will be creating to prevent rehashes
- auto instrs = M.getInstructionCount();
- recordNewModule(id, 2 * instrs);
- recordNode(id, id, NodeType::Module);
- return id;
- }
- return moduleIDs[&M];
- }
-
- NodeId getModuleId(const llvm::Module &m) { return addNode(m); }
-
- template NodeId getModuleId(const T &i) {
- const llvm::Module *module;
-
- constexpr bool parent_is_module =
- std::is_same_v;
- constexpr bool is_argument = std::is_same_v;
- if constexpr (parent_is_module) {
- module = i.getParent();
- } else if constexpr (is_argument) {
- module = i.getParent()->getParent();
- } else {
- module = i.getModule();
- }
-
- assert(module);
- return addNode(*module);
- }
-
- template static std::size_t getIndexInParent(const T &item) {
- const auto &parent = *item.getParent();
- return std::distance(parent.begin(), item.getIterator());
- }
-
- NodeId addNode(const llvm::GlobalVariable &GV) {
- if (globalVarIDs.find(&GV) == globalVarIDs.end()) {
- auto id = next_node_id;
- next_node_id += 1;
- auto module_id = getModuleId(GV);
-
- globalVarIDs[&GV] = id;
- recordNode(module_id, id, NodeType::GlobalVariable);
- return id;
- }
- return globalVarIDs[&GV];
- }
-
- NodeId addNode(const llvm::Function &F) {
- if (functionIDs.find(&F) == functionIDs.end()) {
- auto id = next_node_id;
- next_node_id += 1;
- auto module_id = getModuleId(F);
-
- functionIDs[&F] = id;
- recordNode(module_id, id, NodeType::Function);
- return id;
- }
- return functionIDs[&F];
- }
-
- NodeId addNode(const llvm::Argument &A) {
- if (argumentIDs.find(&A) == argumentIDs.end()) {
- auto id = next_node_id;
- next_node_id += 1;
- auto module_id = getModuleId(A);
-
- argumentIDs[&A] = id;
- recordNode(module_id, id, NodeType::Argument);
- return id;
- }
- return argumentIDs[&A];
- }
-
- NodeId addNode(const llvm::BasicBlock &BB) {
- if (basicBlockIDs.find(&BB) == basicBlockIDs.end()) {
- auto id = next_node_id;
- next_node_id += 1;
- auto module_id = getModuleId(BB);
-
- basicBlockIDs[&BB] = id;
- recordNode(module_id, id, NodeType::BasicBlock);
- return id;
- }
- return basicBlockIDs[&BB];
- }
-
- NodeId addNode(const llvm::Instruction &I) {
- if (instructionIDs.find(&I) == instructionIDs.end()) {
- auto id = next_node_id;
- next_node_id += 1;
- auto module_id = getModuleId(I);
-
- instructionIDs[&I] = id;
- recordNode(module_id, id, NodeType::Instruction);
- return id;
- }
- return instructionIDs[&I];
- }
-
- template
- void addEdge(S &src, D &dst, F &&update_func) {
- auto m1 = getModuleId(src);
- auto m2 = getModuleId(dst);
- assert(m1 == m2);
-
- addEdge(m1, addNode(src), addNode(dst), update_func);
- }
-
- template
- void addEdge(NodeId module, NodeId src, NodeId dst, F &&update_func) {
- recordEdge(module, src, dst, update_func);
- }
-
- template
- void addNodeProp(const N &node, F &&update_func) {
- auto module_id = getModuleId(node);
- recordNodeProp(module_id, addNode(node), update_func);
- }
-
- const std::string serialize() const { return facts.serialize(); }
-};
-
-#endif // RESOLVE_LLVM_LLVMFACTS_HPP
diff --git a/resolve-facts/include/resolve_facts_llvm/resolve_facts_llvm.hpp b/resolve-facts/include/resolve_facts_llvm/binary_facts_llvm.hpp
similarity index 60%
rename from resolve-facts/include/resolve_facts_llvm/resolve_facts_llvm.hpp
rename to resolve-facts/include/resolve_facts_llvm/binary_facts_llvm.hpp
index 9fa4da0c6..4a9236cbd 100644
--- a/resolve-facts/include/resolve_facts_llvm/resolve_facts_llvm.hpp
+++ b/resolve-facts/include/resolve_facts_llvm/binary_facts_llvm.hpp
@@ -3,8 +3,7 @@
* LGPL-3; See LICENSE.txt in the repo root for details.
*/
-#include "resolve_facts/resolve_facts.hpp"
-#include "resolve_facts_llvm/LLVMFacts.hpp"
+#include "resolve_facts_llvm/BinaryLLVMFacts.hpp"
#include "llvm/ADT/SmallString.h"
#include "llvm/ADT/SmallVector.h"
@@ -21,28 +20,19 @@
#include "llvm/IR/PassManager.h"
#include "llvm/Passes/PassBuilder.h"
#include "llvm/Passes/PassPlugin.h"
-#include "llvm/Support/Compression.h"
-#include "llvm/Support/FileSystem.h"
-#include "llvm/Support/Path.h"
-#include "llvm/Support/raw_ostream.h"
#include "llvm/Transforms/Utils/ModuleUtils.h"
using namespace llvm;
namespace resolve {
-extern ProgramFacts all_facts;
-extern LLVMFacts facts;
+std::string binaryTypeToString(const Type &type);
-std::string debugLocToString(DebugLoc dbgLoc);
+void getBinaryGlobalFacts(BinaryLLVMFacts &facts, GlobalVariable &G);
-std::string typeToString(const Type &type);
+void getBinaryFunctionFacts(BinaryLLVMFacts &facts, Function &F);
-void getGlobalFacts(GlobalVariable &G);
-
-void getFunctionFacts(Function &F);
-
-void getModuleFacts(Module &M);
+void getBinaryModuleFacts(BinaryLLVMFacts &facts, Module &M);
// Embed the accumulated facts into custom ELF sections.
-void embedFacts(Module &M);
+void embedBinaryFacts(Module &M, ArrayRef facts);
} // namespace resolve
diff --git a/resolve-facts/libs/reach/distmap.cpp b/resolve-facts/libs/reach/distmap.cpp
index 744940617..25a96b859 100644
--- a/resolve-facts/libs/reach/distmap.cpp
+++ b/resolve-facts/libs/reach/distmap.cpp
@@ -9,11 +9,97 @@
#include
#include "reach/distmap.hpp"
+#include "reach/facts_view.hpp"
#include "reach/search.hpp"
#include "reach/util.hpp"
using namespace std;
+namespace {
+
+template
+void for_each_function_instruction(const reach_facts::ProgramFactsView &pf,
+ const NNodeId function, Function callback) {
+ const auto [module_id, function_id] = function;
+ const auto module = pf.module(module_id);
+ for (const auto &contains_block : module.out_edges(function_id)) {
+ if (!reach_facts::edge_has_kind(contains_block,
+ facts_rs::EdgeKind::Contains) ||
+ module.node(contains_block.dst).type() !=
+ facts_rs::NodeType::BasicBlock) {
+ continue;
+ }
+ for (const auto &contains_instruction :
+ module.out_edges(contains_block.dst)) {
+ if (reach_facts::edge_has_kind(contains_instruction,
+ facts_rs::EdgeKind::Contains) &&
+ module.node(contains_instruction.dst).type() ==
+ facts_rs::NodeType::Instruction) {
+ callback(make_pair(module_id, contains_instruction.dst));
+ }
+ }
+ }
+}
+
+} // namespace
+
+distmap_blacklist
+distmap::gen(const facts_rs::FactsBuf *facts, const NNodeId &dst, bool dynlink,
+ const optional> &loaded_syms) {
+ const reach_facts::ProgramFactsView pf{facts};
+ if (!pf.contains_node(dst)) {
+ throw runtime_error("distmap::gen: node not found");
+ }
+ const auto target = pf.node(dst);
+ if (target.type() != facts_rs::NodeType::Function) {
+ throw runtime_error("distmap::gen: node is not a function");
+ }
+ const auto target_name = target.name();
+ if (!target_name) {
+ throw runtime_error("distmap::gen: target function has no name");
+ }
+
+ const auto graph = graph::build_instr_cfg(facts, dynlink, loaded_syms);
+ auto distances = search::min_distances(graph.edges, dst);
+
+ for_each_function_instruction(
+ pf, dst, [&](const NNodeId instruction) { distances[instruction] = 0; });
+
+ for (uint32_t module_id = 0; module_id < pf.module_count(); ++module_id) {
+ const auto module = pf.module(module_id);
+ for (uint32_t node_id = 0; node_id < module.nodes().size(); ++node_id) {
+ const auto node = module.node(node_id);
+ if (node.linkage() == facts_rs::Linkage::ExternalLinkage &&
+ node.name() == target_name) {
+ for_each_function_instruction(
+ pf, make_pair(module_id, node_id),
+ [&](const NNodeId instruction) { distances[instruction] = 0; });
+ }
+ }
+ }
+
+ resolve_facts::NodeMap instruction_distances;
+ for (const auto &[id, distance] : distances) {
+ if (pf.node(id).type() == facts_rs::NodeType::Instruction) {
+ instruction_distances.emplace(id, distance);
+ }
+ }
+
+ unordered_set blacklist;
+ for (uint32_t module_id = 0; module_id < pf.module_count(); ++module_id) {
+ const auto module = pf.module(module_id);
+ for (uint32_t node_id = 0; node_id < module.nodes().size(); ++node_id) {
+ const auto id = make_pair(module_id, node_id);
+ if (module.node(node_id).type() == facts_rs::NodeType::Instruction &&
+ !instruction_distances.contains(id)) {
+ blacklist.insert(id);
+ }
+ }
+ }
+
+ return {move(instruction_distances), move(blacklist)};
+}
+
distmap_blacklist
distmap::gen(const reach_facts::database &db, const NNodeId &dst, bool dynlink,
const optional> &loaded_syms) {
diff --git a/resolve-facts/libs/reach/facts.cpp b/resolve-facts/libs/reach/facts.cpp
index 956f75b12..1790f391c 100644
--- a/resolve-facts/libs/reach/facts.cpp
+++ b/resolve-facts/libs/reach/facts.cpp
@@ -10,6 +10,7 @@
#include
#include "reach/facts.hpp"
+#include "reach/facts_view.hpp"
#include "reach/util.hpp"
using namespace resolve_facts;
@@ -101,6 +102,25 @@ database reach_facts::load(const fs::path &facts_dir, LoadOptions options) {
return load(facts, options);
}
+vector
+reach_facts::find_functions_by_name_suffix(const facts_rs::FactsBuf *facts,
+ const string_view suffix) {
+ const ProgramFactsView pf{facts};
+ vector matches;
+ for (uint32_t mid = 0; mid < pf.module_count(); ++mid) {
+ const auto module = pf.module(mid);
+ for (uint32_t nid = 0; nid < module.nodes().size(); ++nid) {
+ const auto node = module.node(nid);
+ const auto name = node.name();
+ if (node.type() == facts_rs::NodeType::Function && name &&
+ name->ends_with(suffix)) {
+ matches.emplace_back(mid, nid);
+ }
+ }
+ }
+ return matches;
+}
+
// These checks ensure that the hashmap lookups in
// graph::build_call_graph and graph::build_cfg will succeed.
bool reach_facts::validate(const database &db) {
diff --git a/resolve-facts/libs/reach/facts_view.hpp b/resolve-facts/libs/reach/facts_view.hpp
new file mode 100644
index 000000000..b1f2f282f
--- /dev/null
+++ b/resolve-facts/libs/reach/facts_view.hpp
@@ -0,0 +1,169 @@
+/*
+ * Copyright (c) 2025 Riverside Research.
+ * LGPL-3; See LICENSE.txt in the repo root for details.
+ */
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "facts_rs.hpp"
+
+namespace reach_facts {
+
+static_assert(std::endian::native == std::endian::little);
+static_assert(sizeof(facts_rs::Node) == 32);
+static_assert(alignof(facts_rs::Node) == 4);
+static_assert(sizeof(facts_rs::Edge) == 12);
+static_assert(alignof(facts_rs::Edge) == 4);
+
+class NodeView {
+ const facts_rs::Node *node_;
+ std::span strings_;
+
+ std::string_view string_at(const facts_rs::Interned id) const {
+ const auto offset = static_cast(id);
+ assert(offset + sizeof(uint32_t) <= strings_.size());
+
+ uint32_t length;
+ std::memcpy(&length, strings_.data() + offset, sizeof(length));
+ const auto start = offset + sizeof(length);
+ assert(start + length <= strings_.size());
+ return {reinterpret_cast(strings_.data() + start), length};
+ }
+
+ std::optional string(const uint32_t property,
+ const facts_rs::Interned id) const {
+ if ((node_->meta & property) == 0) {
+ return {};
+ }
+ return string_at(id);
+ }
+
+public:
+ NodeView(const facts_rs::Node &node, const std::span strings)
+ : node_(&node), strings_(strings) {}
+
+ facts_rs::NodeType type() const {
+ return static_cast(
+ (node_->meta & facts_rs::NODE_TYPE_MASK) >> facts_rs::NODE_TYPE_SHIFT);
+ }
+
+ std::optional name() const {
+ return string(facts_rs::P_NAME, node_->name);
+ }
+
+ std::optional linkage() const {
+ if ((node_->meta & facts_rs::P_LINKAGE) == 0) {
+ return {};
+ }
+ return static_cast(
+ (node_->meta & facts_rs::LINKAGE_MASK) >> facts_rs::LINKAGE_SHIFT);
+ }
+
+ std::optional call_type() const {
+ if ((node_->meta & facts_rs::P_CALL_TYPE) == 0) {
+ return {};
+ }
+ return static_cast(
+ (node_->meta & facts_rs::CALL_TYPE_MASK) >> facts_rs::CALL_TYPE_SHIFT);
+ }
+
+ std::optional source_file() const {
+ return string(facts_rs::P_SOURCE_FILE, node_->source_file);
+ }
+
+ std::optional function_type() const {
+ return string(facts_rs::P_FUNCTION_TYPE, node_->function_type);
+ }
+
+ bool address_taken() const {
+ return (node_->meta & facts_rs::P_ADDRESS_TAKEN) != 0;
+ }
+};
+
+inline bool edge_has_kind(const facts_rs::Edge &edge,
+ const facts_rs::EdgeKind kind) {
+ return (edge.kinds & (1u << static_cast(kind))) != 0;
+}
+
+class ModuleView {
+ facts_rs::FactsModuleView module_;
+
+public:
+ explicit ModuleView(const facts_rs::FactsModuleView module)
+ : module_(module) {}
+
+ std::span nodes() const {
+ return {module_.nodes, module_.node_count};
+ }
+
+ std::span edges() const {
+ return {module_.edges, module_.edge_count};
+ }
+
+ std::span out_edges(const facts_rs::NodeID id) const {
+ const auto all = edges();
+ const auto begin = std::lower_bound(
+ all.begin(), all.end(), id,
+ [](const facts_rs::Edge &edge, const facts_rs::NodeID value) {
+ return edge.src < value;
+ });
+ const auto end = std::upper_bound(
+ begin, all.end(), id,
+ [](const facts_rs::NodeID value, const facts_rs::Edge &edge) {
+ return value < edge.src;
+ });
+ return all.subspan(begin - all.begin(), end - begin);
+ }
+
+ bool contains(const facts_rs::NodeID id) const { return id < nodes().size(); }
+
+ NodeView node(const facts_rs::NodeID id) const {
+ assert(contains(id));
+ return {nodes()[id], {module_.string_pool, module_.string_pool_len}};
+ }
+};
+
+class ProgramFactsView {
+ std::vector modules_;
+
+public:
+ explicit ProgramFactsView(const facts_rs::FactsBuf *facts) {
+ if (!facts) {
+ throw std::invalid_argument("null FactsBuf");
+ }
+ facts_rs::FactsModuleCursor cursor{};
+ facts_rs::FactsModuleView module{};
+ while (facts_rs::facts_module_next(facts, &cursor, &module)) {
+ modules_.push_back(module);
+ }
+ }
+
+ size_t module_count() const { return modules_.size(); }
+
+ ModuleView module(const uint32_t index) const {
+ assert(index < modules_.size());
+ return ModuleView{modules_[index]};
+ }
+
+ bool contains_node(const std::pair id) const {
+ return id.first < modules_.size() && module(id.first).contains(id.second);
+ }
+
+ NodeView node(const std::pair id) const {
+ return module(id.first).node(id.second);
+ }
+};
+
+} // namespace reach_facts
diff --git a/resolve-facts/libs/reach/ffi.cpp b/resolve-facts/libs/reach/ffi.cpp
new file mode 100644
index 000000000..c3733e3a1
--- /dev/null
+++ b/resolve-facts/libs/reach/ffi.cpp
@@ -0,0 +1,216 @@
+/*
+ * Copyright (c) 2025 Riverside Research.
+ * LGPL-3; See LICENSE.txt in the repo root for details.
+ */
+
+#include "reach/ffi.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "reach/graph.hpp"
+#include "reach/search.hpp"
+
+struct ReachGraph {
+ graph::T value;
+};
+
+struct ReachPath {
+ std::vector nodes;
+ std::vector edges;
+};
+
+struct ReachQueryResult {
+ std::vector paths;
+};
+
+struct ReachError {
+ std::string message;
+};
+
+namespace {
+
+void clear_error(ReachError **error) {
+ if (error) {
+ *error = nullptr;
+ }
+}
+
+void set_error(ReachError **error, std::string message) {
+ if (error) {
+ *error = new ReachError{std::move(message)};
+ }
+}
+
+std::string copy_string(const ReachStringView string) {
+ if (string.len == 0) {
+ return {};
+ }
+ if (!string.data) {
+ throw std::invalid_argument("null loaded-symbol string");
+ }
+ return {reinterpret_cast(string.data), string.len};
+}
+
+std::optional>
+loaded_symbols(const ReachBuildOptions *options) {
+ if (!options || options->filter_loaded_symbols == 0) {
+ return {};
+ }
+ if (options->loaded_symbol_count != 0 && !options->loaded_symbols) {
+ throw std::invalid_argument("null loaded-symbol array");
+ }
+
+ std::vector symbols;
+ symbols.reserve(options->loaded_symbol_count);
+ for (size_t i = 0; i < options->loaded_symbol_count; ++i) {
+ const auto &symbol = options->loaded_symbols[i];
+ symbols.push_back(
+ {copy_string(symbol.symbol), copy_string(symbol.library)});
+ }
+ return symbols;
+}
+
+NNodeId node_id(const ReachNodeId id) { return {id.module, id.node}; }
+
+ReachNodeId node_id(const NNodeId id) { return {id.first, id.second}; }
+
+ReachEdgeType edge_type(const graph::EdgeType type) {
+ switch (type) {
+ case graph::EdgeType::DirectCall:
+ return REACH_EDGE_DIRECT_CALL;
+ case graph::EdgeType::IndirectCall:
+ return REACH_EDGE_INDIRECT_CALL;
+ case graph::EdgeType::Contains:
+ return REACH_EDGE_CONTAINS;
+ case graph::EdgeType::Succ:
+ return REACH_EDGE_SUCCESSOR;
+ case graph::EdgeType::Extern:
+ return REACH_EDGE_EXTERNAL;
+ case graph::EdgeType::ExternIndirectCall:
+ return REACH_EDGE_EXTERNAL_INDIRECT_CALL;
+ case graph::EdgeType::Self:
+ throw std::logic_error("self edge cannot appear between path nodes");
+ }
+ throw std::logic_error("unknown reach edge type");
+}
+
+ReachPath convert_path(const std::vector &path) {
+ ReachPath result;
+ result.nodes.reserve(path.size());
+ result.edges.reserve(path.empty() ? 0 : path.size() - 1);
+
+ for (const auto &edge : path) {
+ result.nodes.push_back(node_id(edge.node));
+ }
+ std::reverse(result.nodes.begin(), result.nodes.end());
+
+ for (auto it = path.rbegin(); it != path.rend(); ++it) {
+ if (std::next(it) != path.rend()) {
+ result.edges.push_back(edge_type(it->type));
+ }
+ }
+ return result;
+}
+
+} // namespace
+
+extern "C" ReachGraph *reach_graph_build(const ReachFactsBuf *facts,
+ const ReachBuildOptions *options,
+ ReachError **error) {
+ clear_error(error);
+ try {
+ const auto symbols = loaded_symbols(options);
+ const auto dynlink = options && options->dynlink != 0;
+ return new ReachGraph{
+ graph::build_from_program_facts(facts, dynlink, symbols)};
+ } catch (const std::exception &exception) {
+ set_error(error, exception.what());
+ } catch (...) {
+ set_error(error, "unknown error while building reach graph");
+ }
+ return nullptr;
+}
+
+extern "C" void reach_graph_free(ReachGraph *graph) { delete graph; }
+
+extern "C" size_t reach_graph_edge_count(const ReachGraph *graph) {
+ if (!graph) {
+ return 0;
+ }
+
+ size_t count = 0;
+ for (const auto &[_, edges] : graph->value.edges) {
+ count += edges.size();
+ }
+ return count;
+}
+
+extern "C" ReachQueryResult *reach_graph_query(const ReachGraph *graph,
+ const ReachNodeId src,
+ const ReachNodeId dst,
+ const size_t max_paths,
+ ReachError **error) {
+ clear_error(error);
+ try {
+ if (!graph) {
+ throw std::invalid_argument("null reach graph");
+ }
+
+ const auto paths = search::k_paths_yen(graph->value.edges, node_id(dst),
+ node_id(src), max_paths);
+ auto result = std::make_unique();
+ result->paths.reserve(paths.size());
+ for (const auto &path : paths) {
+ result->paths.push_back(convert_path(path));
+ }
+ return result.release();
+ } catch (const std::exception &exception) {
+ set_error(error, exception.what());
+ } catch (...) {
+ set_error(error, "unknown error while querying reach graph");
+ }
+ return nullptr;
+}
+
+extern "C" void reach_query_result_free(ReachQueryResult *result) {
+ delete result;
+}
+
+extern "C" size_t
+reach_query_result_path_count(const ReachQueryResult *result) {
+ return result ? result->paths.size() : 0;
+}
+
+extern "C" uint8_t reach_query_result_path(const ReachQueryResult *result,
+ const size_t index,
+ ReachPathView *path) {
+ if (!result || !path || index >= result->paths.size()) {
+ return 0;
+ }
+
+ const auto &value = result->paths[index];
+ *path = {
+ value.nodes.data(),
+ value.nodes.size(),
+ value.edges.data(),
+ value.edges.size(),
+ };
+ return 1;
+}
+
+extern "C" const uint8_t *reach_error_data(const ReachError *error) {
+ return error ? reinterpret_cast(error->message.data())
+ : nullptr;
+}
+
+extern "C" size_t reach_error_len(const ReachError *error) {
+ return error ? error->message.size() : 0;
+}
+
+extern "C" void reach_error_free(ReachError *error) { delete error; }
diff --git a/resolve-facts/libs/reach/graph.cpp b/resolve-facts/libs/reach/graph.cpp
index efc7b8b88..80d6122c6 100644
--- a/resolve-facts/libs/reach/graph.cpp
+++ b/resolve-facts/libs/reach/graph.cpp
@@ -11,6 +11,7 @@
#include
#include "reach/facts.hpp"
+#include "reach/facts_view.hpp"
#include "reach/graph.hpp"
#include "reach/util.hpp"
@@ -89,6 +90,119 @@ map_loaded_symbols_to_ids(const database &db,
T graph::build_from_program_facts(const ProgramFacts &pf, bool dynlink,
const optional> &loaded_syms) {
+ T g;
+
+ NodeMap calls;
+ NodeMap> bb_calls;
+ unordered_map> address_taken_by_sig;
+ unordered_map> externs_by_name;
+ unordered_set loaded_ids;
+ vector syms;
+ if (loaded_syms.has_value()) {
+ syms = *loaded_syms;
+ }
+
+ for (const auto &[mid, module] : pf.modules) {
+ for (const auto &[eid, edge] : module.edges) {
+ const auto &[src, dst] = eid;
+ const auto sid = make_pair(mid, src);
+ const auto did = make_pair(mid, dst);
+
+ for (const auto kind : edge.kinds) {
+ if (kind == EdgeKind::EntryPoint) {
+ g.addEdge(did, sid, EdgeType::Contains);
+ } else if (kind == EdgeKind::ControlFlowTo) {
+ g.addEdge(did, sid, EdgeType::Succ);
+ } else if (kind == EdgeKind::Calls) {
+ calls.emplace(sid, did);
+ }
+
+ if (kind == EdgeKind::Contains &&
+ module.nodes.at(src).type == NodeType::BasicBlock &&
+ module.nodes.at(dst).call_type.has_value()) {
+ bb_calls[sid].push_back(did);
+ }
+ }
+ }
+
+ for (const auto &[nid, node] : module.nodes) {
+ const auto id = make_pair(mid, nid);
+ if (node.linkage == Linkage::ExternalLinkage) {
+ externs_by_name[*node.name].push_back(id);
+ }
+
+ if (node.address_taken) {
+ address_taken_by_sig[*node.function_type].push_back(id);
+ }
+
+ if (node.type == NodeType::Function && dynlink) {
+ for (const auto &sym : syms) {
+ if (sym.symbol == node.name) {
+ loaded_ids.emplace(id);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ for (const auto &[bb, instrs] : bb_calls) {
+ const auto [mid, bbid] = bb;
+ const auto &module = pf.modules.at(mid);
+ for (const auto &instr : instrs) {
+ const auto [_, iid] = instr;
+ const auto &node = module.nodes.at(iid);
+ if (node.call_type == CallType::Direct) {
+ const auto &call_id = calls.at(instr);
+ g.addEdge(call_id, bb, EdgeType::DirectCall);
+
+ const auto &[_, cid] = call_id;
+ const auto &fn_name = module.nodes.at(cid).name;
+ if (fn_name == "pthread_create") {
+ for (const auto &fn : address_taken_by_sig.at("ptr (ptr)")) {
+ g.addEdge(fn, bb, EdgeType::IndirectCall, INDIRECT_WEIGHT);
+ }
+ }
+ continue;
+ }
+
+ if (address_taken_by_sig.contains(*node.function_type)) {
+ for (const auto &fn : address_taken_by_sig.at(*node.function_type)) {
+ g.addEdge(fn, bb, EdgeType::IndirectCall, INDIRECT_WEIGHT);
+ }
+ }
+
+ if (dynlink) {
+ for (const auto &[_, handles] : externs_by_name) {
+ for (const auto &handle : handles) {
+ const auto &candidate = pf.getNode(handle);
+ if (candidate.type == NodeType::Function &&
+ candidate.function_type == node.function_type &&
+ (!loaded_syms.has_value() || loaded_ids.contains(handle))) {
+ g.addEdge(handle, bb, EdgeType::ExternIndirectCall,
+ INDIRECT_WEIGHT);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ for (const auto &[_, handles] : externs_by_name) {
+ for (size_t i = 0; i < handles.size(); ++i) {
+ for (size_t j = i + 1; j < handles.size(); ++j) {
+ g.addEdge(handles[i], handles[j], EdgeType::Extern, INDIRECT_WEIGHT);
+ g.addEdge(handles[j], handles[i], EdgeType::Extern, INDIRECT_WEIGHT);
+ }
+ }
+ }
+
+ return g;
+}
+
+T graph::build_from_program_facts(const facts_rs::FactsBuf *facts, bool dynlink,
+ const optional> &loaded_syms) {
+ const reach_facts::ProgramFactsView pf{facts};
T g;
@@ -98,11 +212,11 @@ T graph::build_from_program_facts(const ProgramFacts &pf, bool dynlink,
NodeMap> bb_calls;
// For indirect calls we want to get all function that match a signature
- std::unordered_map> address_taken_by_sig;
+ std::unordered_map> address_taken_by_sig;
// We want to be able to link all externs of the same name together
// and also externs to dynamic symbols if applicable.
- unordered_map> externs_by_name;
+ unordered_map> externs_by_name;
std::unordered_set loaded_ids;
std::vector syms;
@@ -110,46 +224,49 @@ T graph::build_from_program_facts(const ProgramFacts &pf, bool dynlink,
syms = *loaded_syms;
}
- for (const auto &[mid, m] : pf.modules) {
+ for (uint32_t mid = 0; mid < pf.module_count(); ++mid) {
+ const auto m = pf.module(mid);
- for (const auto &[eid, e] : m.edges) {
- const auto &[s, d] = eid;
+ for (const auto &e : m.edges()) {
+ const auto s = e.src;
+ const auto d = e.dst;
auto sid = std::make_pair(mid, s);
auto did = std::make_pair(mid, d);
- for (const auto &k : e.kinds) {
- // fn to first block
- if (k == EdgeKind::EntryPoint) {
- g.addEdge(did, sid, EdgeType::Contains);
- // BB control flow
- } else if (k == EdgeKind::ControlFlowTo) {
- g.addEdge(did, sid, EdgeType::Succ);
- } else if (k == EdgeKind::Calls) {
- calls.emplace(sid, did);
- }
+ // fn to first block
+ if (edge_has_kind(e, facts_rs::EdgeKind::EntryPoint)) {
+ g.addEdge(did, sid, EdgeType::Contains);
+ }
+ // BB control flow
+ if (edge_has_kind(e, facts_rs::EdgeKind::ControlFlowTo)) {
+ g.addEdge(did, sid, EdgeType::Succ);
+ }
+ if (edge_has_kind(e, facts_rs::EdgeKind::Calls)) {
+ calls.emplace(sid, did);
+ }
- if (k == EdgeKind::Contains &&
- m.nodes.at(s).type == NodeType::BasicBlock &&
- m.nodes.at(d).call_type.has_value()) {
- bb_calls[sid].push_back(did);
- }
+ if (edge_has_kind(e, facts_rs::EdgeKind::Contains) &&
+ m.node(s).type() == facts_rs::NodeType::BasicBlock &&
+ m.node(d).call_type().has_value()) {
+ bb_calls[sid].push_back(did);
}
}
- for (const auto &[nid, n] : m.nodes) {
+ for (uint32_t nid = 0; nid < m.nodes().size(); ++nid) {
+ const auto n = m.node(nid);
auto id = std::make_pair(mid, nid);
- if (n.linkage == Linkage::ExternalLinkage) {
- externs_by_name[*n.name].push_back(id);
+ if (n.linkage() == facts_rs::Linkage::ExternalLinkage) {
+ externs_by_name[*n.name()].push_back(id);
}
- if (n.address_taken == true) {
- auto sig = *n.function_type;
+ if (n.address_taken()) {
+ auto sig = *n.function_type();
address_taken_by_sig[sig].push_back(id);
}
- if (n.type == NodeType::Function && dynlink) {
+ if (n.type() == facts_rs::NodeType::Function && dynlink) {
for (const auto &sym : syms) {
- if (sym.symbol == n.name) {
+ if (n.name() && sym.symbol == *n.name()) {
loaded_ids.emplace(id);
break;
}
@@ -161,13 +278,13 @@ T graph::build_from_program_facts(const ProgramFacts &pf, bool dynlink,
// Calls
for (const auto &[bb, instrs] : bb_calls) {
const auto [mid, bbid] = bb;
- const auto &module = pf.modules.at(mid);
+ const auto module = pf.module(mid);
for (const auto &instr : instrs) {
const auto [_, iid] = instr;
- const auto &n = module.nodes.at(iid);
- const auto &call_ty = n.call_type;
+ const auto n = module.node(iid);
+ const auto call_ty = n.call_type();
// If direct, add one edge.
- if (call_ty == CallType::Direct) {
+ if (call_ty == facts_rs::CallType::Direct) {
const auto &call_id = calls.at(instr);
g.addEdge(call_id, bb, EdgeType::DirectCall);
@@ -176,8 +293,8 @@ T graph::build_from_program_facts(const ProgramFacts &pf, bool dynlink,
// "ptr (ptr)".
const auto &[_, cid] = call_id;
- const auto &fn_name = module.nodes.at(cid).name;
- if (fn_name == "pthread_create") {
+ const auto fn_name = module.node(cid).name();
+ if (fn_name && *fn_name == "pthread_create") {
for (const auto &fn : address_taken_by_sig.at("ptr (ptr)")) {
g.addEdge(fn, bb, EdgeType::IndirectCall, INDIRECT_WEIGHT);
}
@@ -186,9 +303,9 @@ T graph::build_from_program_facts(const ProgramFacts &pf, bool dynlink,
continue;
}
- if (address_taken_by_sig.contains(*n.function_type)) {
+ if (address_taken_by_sig.contains(*n.function_type())) {
// Else indirect. Add edges for all compatible address-taken functions.
- for (const auto &fn : address_taken_by_sig.at(*n.function_type)) {
+ for (const auto &fn : address_taken_by_sig.at(*n.function_type())) {
g.addEdge(fn, bb, EdgeType::IndirectCall, INDIRECT_WEIGHT);
}
}
@@ -199,9 +316,9 @@ T graph::build_from_program_facts(const ProgramFacts &pf, bool dynlink,
if (dynlink) {
for (const auto &[_, handles] : externs_by_name) {
for (const auto &h : handles) {
- const auto &n2 = pf.getNode(h);
- if (n2.type == NodeType::Function &&
- n2.function_type == n.function_type &&
+ const auto n2 = pf.node(h);
+ if (n2.type() == facts_rs::NodeType::Function &&
+ n2.function_type() == n.function_type() &&
(!loaded_syms.has_value() || loaded_ids.contains(h))) {
g.addEdge(h, bb, EdgeType::ExternIndirectCall, INDIRECT_WEIGHT);
}
@@ -252,6 +369,170 @@ T graph::build_from_program_facts(const ProgramFacts &pf, bool dynlink,
// Same thing here as [build_cfg] (see above) wrt. Call edges going
// through intermediate function nodes.
+T graph::build_instr_cfg(const facts_rs::FactsBuf *facts, bool dynlink,
+ const optional> &loaded_syms) {
+ const reach_facts::ProgramFactsView pf{facts};
+ T g;
+
+ unordered_map> address_taken_by_sig;
+ unordered_map> externs_by_name;
+ unordered_set loaded_ids;
+
+ for (uint32_t mid = 0; mid < pf.module_count(); ++mid) {
+ const auto module = pf.module(mid);
+ for (uint32_t nid = 0; nid < module.nodes().size(); ++nid) {
+ const auto node = module.node(nid);
+ const auto id = make_pair(mid, nid);
+
+ if (node.linkage() == facts_rs::Linkage::ExternalLinkage) {
+ externs_by_name[*node.name()].push_back(id);
+ }
+ if (node.address_taken()) {
+ address_taken_by_sig[*node.function_type()].push_back(id);
+ }
+ if (dynlink && loaded_syms &&
+ node.type() == facts_rs::NodeType::Function) {
+ for (const auto &loaded : *loaded_syms) {
+ if (node.name() && loaded.symbol == *node.name()) {
+ loaded_ids.insert(id);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ auto instruction_bounds = [](const reach_facts::ModuleView module,
+ const facts_rs::NodeID bb) {
+ pair, optional> result;
+ for (const auto &edge : module.out_edges(bb)) {
+ if (edge_has_kind(edge, facts_rs::EdgeKind::Contains) &&
+ module.node(edge.dst).type() == facts_rs::NodeType::Instruction) {
+ if (!result.first) {
+ result.first = edge.dst;
+ }
+ result.second = edge.dst;
+ }
+ }
+ return result;
+ };
+
+ for (uint32_t mid = 0; mid < pf.module_count(); ++mid) {
+ const auto module = pf.module(mid);
+
+ for (uint32_t bb = 0; bb < module.nodes().size(); ++bb) {
+ if (module.node(bb).type() != facts_rs::NodeType::BasicBlock) {
+ continue;
+ }
+
+ optional previous;
+ for (const auto &edge : module.out_edges(bb)) {
+ if (!edge_has_kind(edge, facts_rs::EdgeKind::Contains) ||
+ module.node(edge.dst).type() != facts_rs::NodeType::Instruction) {
+ continue;
+ }
+
+ const auto instruction = edge.dst;
+ if (previous) {
+ g.addEdge(make_pair(mid, instruction), make_pair(mid, *previous),
+ EdgeType::Succ);
+ }
+ previous = instruction;
+
+ const auto node = module.node(instruction);
+ const auto call_type = node.call_type();
+ if (!call_type) {
+ continue;
+ }
+
+ const auto instruction_id = make_pair(mid, instruction);
+ if (*call_type == facts_rs::CallType::Direct) {
+ optional target;
+ for (const auto &call : module.out_edges(instruction)) {
+ if (edge_has_kind(call, facts_rs::EdgeKind::Calls)) {
+ target = make_pair(mid, call.dst);
+ break;
+ }
+ }
+ if (!target) {
+ throw runtime_error("direct call has no call edge");
+ }
+
+ g.addEdge(*target, instruction_id, EdgeType::DirectCall);
+ const auto [_, target_node] = *target;
+ if (module.node(target_node).name() == "pthread_create") {
+ if (const auto it = address_taken_by_sig.find("ptr (ptr)");
+ it != address_taken_by_sig.end()) {
+ for (const auto &function : it->second) {
+ g.addEdge(function, instruction_id, EdgeType::IndirectCall,
+ INDIRECT_WEIGHT);
+ }
+ }
+ }
+ continue;
+ }
+
+ const auto signature = node.function_type();
+ if (!signature) {
+ throw runtime_error("indirect call has no function type");
+ }
+ if (const auto it = address_taken_by_sig.find(*signature);
+ it != address_taken_by_sig.end()) {
+ for (const auto &function : it->second) {
+ g.addEdge(function, instruction_id, EdgeType::IndirectCall,
+ INDIRECT_WEIGHT);
+ }
+ }
+
+ if (dynlink) {
+ for (const auto &[_, handles] : externs_by_name) {
+ for (const auto &handle : handles) {
+ const auto function = pf.node(handle);
+ if (function.type() == facts_rs::NodeType::Function &&
+ function.function_type() == signature &&
+ (!loaded_syms || loaded_ids.contains(handle))) {
+ g.addEdge(handle, instruction_id, EdgeType::ExternIndirectCall,
+ INDIRECT_WEIGHT);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ for (const auto &edge : module.edges()) {
+ if (edge_has_kind(edge, facts_rs::EdgeKind::EntryPoint)) {
+ const auto bounds = instruction_bounds(module, edge.dst);
+ if (!bounds.first) {
+ throw runtime_error("entry block has no instruction");
+ }
+ g.addEdge(make_pair(mid, *bounds.first), make_pair(mid, edge.src),
+ EdgeType::Contains);
+ }
+ if (edge_has_kind(edge, facts_rs::EdgeKind::ControlFlowTo)) {
+ const auto source = instruction_bounds(module, edge.src);
+ const auto destination = instruction_bounds(module, edge.dst);
+ if (!source.second || !destination.first) {
+ throw runtime_error("control-flow block has no instruction");
+ }
+ g.addEdge(make_pair(mid, *destination.first),
+ make_pair(mid, *source.second), EdgeType::Succ);
+ }
+ }
+ }
+
+ for (const auto &[_, handles] : externs_by_name) {
+ for (size_t i = 0; i < handles.size(); ++i) {
+ for (size_t j = i + 1; j < handles.size(); ++j) {
+ g.addEdge(handles[i], handles[j], EdgeType::Extern, INDIRECT_WEIGHT);
+ g.addEdge(handles[j], handles[i], EdgeType::Extern, INDIRECT_WEIGHT);
+ }
+ }
+ }
+
+ return g;
+}
+
T graph::build_instr_cfg(const database &db, bool dynlink,
const optional> &loaded_syms) {
const auto loaded_ids = map_loaded_symbols_to_ids(db, loaded_syms);
diff --git a/resolve-facts/libs/resolve_facts_llvm/binary_facts_llvm.cpp b/resolve-facts/libs/resolve_facts_llvm/binary_facts_llvm.cpp
new file mode 100644
index 000000000..fa9a358d2
--- /dev/null
+++ b/resolve-facts/libs/resolve_facts_llvm/binary_facts_llvm.cpp
@@ -0,0 +1,164 @@
+/*
+ * Copyright (c) 2025 Riverside Research.
+ * LGPL-3; See LICENSE.txt in the repo root for details.
+ */
+
+#include "resolve_facts_llvm/binary_facts_llvm.hpp"
+
+#include // For std::getenv
+#include
+#include