From e5d887b3209cc7dfa80f239c40ea7e6ae6b0c41d Mon Sep 17 00:00:00 2001 From: Ryan Zmuda Date: Tue, 18 Aug 2026 11:59:12 -0400 Subject: [PATCH 01/16] facts: add flat binary schema and writer --- .gitignore | 5 +- docs/development/building-from-source.md | 1 + resolve-facts/CMakeLists.txt | 38 ++ resolve-facts/Config.cmake.in | 11 +- resolve-facts/README.md | 4 + resolve-facts/rs/Cargo.lock | 65 +++ resolve-facts/rs/Cargo.toml | 10 + resolve-facts/rs/cbindgen.toml | 21 + resolve-facts/rs/src/builder.rs | 377 ++++++++++++++++ resolve-facts/rs/src/interner.rs | 32 ++ resolve-facts/rs/src/lib.rs | 12 + resolve-facts/rs/src/schema.rs | 541 +++++++++++++++++++++++ resolve-facts/rs/src/utils.rs | 6 + resolve-facts/rs/src/writer.rs | 56 +++ scripts/install-deps-ci.sh | 2 + 15 files changed, 1178 insertions(+), 3 deletions(-) create mode 100644 resolve-facts/rs/Cargo.lock create mode 100644 resolve-facts/rs/Cargo.toml create mode 100644 resolve-facts/rs/cbindgen.toml create mode 100644 resolve-facts/rs/src/builder.rs create mode 100644 resolve-facts/rs/src/interner.rs create mode 100644 resolve-facts/rs/src/lib.rs create mode 100644 resolve-facts/rs/src/schema.rs create mode 100644 resolve-facts/rs/src/utils.rs create mode 100644 resolve-facts/rs/src/writer.rs 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/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/resolve-facts/CMakeLists.txt b/resolve-facts/CMakeLists.txt index aa11efe9c..a0fc96deb 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 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) diff --git a/resolve-facts/Config.cmake.in b/resolve-facts/Config.cmake.in index 6909bc4fb..9ca79c112 100644 --- a/resolve-facts/Config.cmake.in +++ b/resolve-facts/Config.cmake.in @@ -1,9 +1,16 @@ @PACKAGE_INIT@ -include("${CMAKE_CURRENT_LIST_DIR}/ResolveFactsTargets.cmake") - include(CMakeFindDependencyMacro) find_dependency(glaze) + +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..c3e72624f 100644 --- a/resolve-facts/README.md +++ b/resolve-facts/README.md @@ -11,3 +11,7 @@ Tools for creating and querying RESOLVE binary metadata, including the `reach` t - Facts: - `reach` tool: + +## 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/rs/Cargo.lock b/resolve-facts/rs/Cargo.lock new file mode 100644 index 000000000..f95a5febc --- /dev/null +++ b/resolve-facts/rs/Cargo.lock @@ -0,0 +1,65 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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", +] + +[[package]] +name = "facts-rs" +version = "0.1.0" +dependencies = [ + "bytemuck", +] + +[[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 = "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 = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/resolve-facts/rs/Cargo.toml b/resolve-facts/rs/Cargo.toml new file mode 100644 index 000000000..725335a9f --- /dev/null +++ b/resolve-facts/rs/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "facts-rs" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["rlib", "staticlib", "cdylib"] + +[dependencies] +bytemuck = { version = "1", features = ["derive"] } diff --git a/resolve-facts/rs/cbindgen.toml b/resolve-facts/rs/cbindgen.toml new file mode 100644 index 000000000..babadc1ac --- /dev/null +++ b/resolve-facts/rs/cbindgen.toml @@ -0,0 +1,21 @@ +language = "C++" +namespace = "facts_rs" +pragma_once = true +include_guard = "RESOLVE_FACTS_RS_H" +header = """ +/* + * Generated by cbindgen from resolve-facts/rs. Do not edit by hand. + */ +""" + +[parse] +parse_deps = false + +[parse.expand] +crates = ["facts-rs"] + +[export] +include = [ + "Node", + "Edge", +] diff --git a/resolve-facts/rs/src/builder.rs b/resolve-facts/rs/src/builder.rs new file mode 100644 index 000000000..aff4d9621 --- /dev/null +++ b/resolve-facts/rs/src/builder.rs @@ -0,0 +1,377 @@ +use std::collections::*; +use std::mem::*; + +use crate::interner::*; +use crate::schema::*; +use crate::utils::*; +use crate::writer::*; + +pub type ModuleHandle = u32; +pub const INVALID_ID: u32 = u32::MAX; + +struct ModuleBuilder { + nodes: Vec, + edges: Vec, + pool: Interner, + edge_indexes: HashMap<(NodeID, NodeID), usize>, +} + +impl ModuleBuilder { + fn new(hint: usize) -> Self { + let edge_hint = hint.saturating_mul(2); + Self { + nodes: Vec::with_capacity(hint), + edges: Vec::with_capacity(edge_hint), + pool: Interner::default(), + edge_indexes: HashMap::with_capacity(edge_hint), + } + } + + fn add_node(&mut self, ty: NodeType) -> NodeID { + let dense_id = u32::try_from(self.nodes.len()).expect("module has too many nodes"); + self.nodes.push(Node::new(ty)); + dense_id + } + + fn node_mut(&mut self, id: NodeID) -> Option<&mut Node> { + self.nodes.get_mut(id as usize) + } + + fn add_edge(&mut self, src: NodeID, dst: NodeID, kind: EdgeKind) -> bool { + if src as usize >= self.nodes.len() || dst as usize >= self.nodes.len() { + return false; + } + + let id = (src, dst); + let kind = 1u32 << kind as u8; + if let Some(&index) = self.edge_indexes.get(&id) { + self.edges[index].kinds |= kind; + return true; + } + + self.edge_indexes.insert(id, self.edges.len()); + self.edges.push(Edge { + src, + dst, + kinds: kind, + }); + true + } + + fn intern_for_node(&mut self, id: NodeID, value: &str) -> Option<(NodeID, Interned)> { + self.nodes.get(id as usize)?; + let interned = self.pool.intern(value); + Some((id, interned)) + } + + fn word_len(&self) -> usize { + size_of::() / size_of::() + + self.nodes.len() * size_of::() / size_of::() + + self.edges.len() * size_of::() / size_of::() + + self.pool.bytes().len().div_ceil(size_of::()) + } + + fn serialize_into(mut self, words: &mut Vec) { + assert!( + self.nodes + .first() + .is_some_and(|node| node.node_type_raw() == NodeType::Module as u8), + "module node 0 must have type Module" + ); + let pool_len = self.pool.bytes().len(); + let padded_pool_len = pool_len.checked_add(3).expect("intern pool is too large") & !3; + let header = ModuleHeader { + version: FORMAT_VERSION, + node_count: u32::try_from(self.nodes.len()).expect("module has too many nodes"), + edge_count: u32::try_from(self.edges.len()).expect("module has too many edges"), + string_pool_len: u32::try_from(padded_pool_len) + .expect("module intern pool exceeds 4 GiB"), + }; + + words.extend_from_slice(bytemuck::cast_slice(std::slice::from_ref(&header))); + words.extend_from_slice(bytemuck::cast_slice(&self.nodes)); + + self.edges.sort_unstable_by_key(|edge| (edge.src, edge.dst)); + words.extend_from_slice(bytemuck::cast_slice(&self.edges)); + + for chunk in self.pool.bytes().chunks(size_of::()) { + let mut bytes = [0; size_of::()]; + bytes[..chunk.len()].copy_from_slice(chunk); + words.push(u32::from_ne_bytes(bytes)); + } + } +} + +#[derive(Default)] +pub struct FactsBuilder { + modules: Vec, +} + +impl FactsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn add_module(&mut self, hint: usize) -> ModuleHandle { + let index = u32::try_from(self.modules.len()).expect("program has too many modules"); + self.modules.push(ModuleBuilder::new(hint)); + index + } + + pub fn add_node(&mut self, module: ModuleHandle, ty: NodeType) -> Option { + Some(self.module_mut(module)?.add_node(ty)) + } + + pub fn add_edge( + &mut self, + module: ModuleHandle, + src: NodeID, + dst: NodeID, + kind: EdgeKind, + ) -> bool { + self.module_mut(module) + .is_some_and(|module| module.add_edge(src, dst, kind)) + } + + pub fn set_node_idx(&mut self, module: ModuleHandle, node: NodeID, value: u32) -> bool { + self.set_node(module, node, |node| { + node.idx = value; + node.set_present(P_IDX); + }) + } + + pub fn set_node_name(&mut self, module: ModuleHandle, node: NodeID, value: &str) -> bool { + self.set_node_string(module, node, value, |node, value| { + node.name = value; + node.set_present(P_NAME); + }) + } + + pub fn set_node_opcode(&mut self, module: ModuleHandle, node: NodeID, value: &str) -> bool { + self.set_node_string(module, node, value, |node, value| { + node.opcode = value; + node.set_present(P_OPCODE); + }) + } + + pub fn set_node_linkage(&mut self, module: ModuleHandle, node: NodeID, value: Linkage) -> bool { + self.set_node(module, node, |node| node.set_linkage(value)) + } + + pub fn set_node_call_type( + &mut self, + module: ModuleHandle, + node: NodeID, + value: CallType, + ) -> bool { + self.set_node(module, node, |node| node.set_call_type(value)) + } + + pub fn set_node_source_loc( + &mut self, + module: ModuleHandle, + node: NodeID, + line: u32, + col: u32, + ) -> bool { + self.set_node(module, node, |node| { + node.source_line = line; + node.source_col = col; + node.set_present(P_SOURCE_LOC); + }) + } + + pub fn set_node_source_file( + &mut self, + module: ModuleHandle, + node: NodeID, + value: &str, + ) -> bool { + self.set_node_string(module, node, value, |node, value| { + node.source_file = value; + node.set_present(P_SOURCE_FILE); + }) + } + + pub fn set_node_function_type( + &mut self, + module: ModuleHandle, + node: NodeID, + value: &str, + ) -> bool { + self.set_node_string(module, node, value, |node, value| { + node.function_type = value; + node.set_present(P_FUNCTION_TYPE); + }) + } + + pub fn set_node_address_taken( + &mut self, + module: ModuleHandle, + node: NodeID, + value: bool, + ) -> bool { + self.set_node(module, node, |node| { + if value { + node.set_present(P_ADDRESS_TAKEN); + } else { + node.meta &= !P_ADDRESS_TAKEN; + } + }) + } + + pub fn freeze(self) -> FactsBuf { + let capacity = self.modules.iter().map(ModuleBuilder::word_len).sum(); + let mut words = Vec::with_capacity(capacity); + for module in self.modules { + module.serialize_into(&mut words); + } + + FactsBuf::from_words(words) + } + + fn module_mut(&mut self, module: ModuleHandle) -> Option<&mut ModuleBuilder> { + self.modules.get_mut(module as usize) + } + + fn set_node( + &mut self, + module: ModuleHandle, + node: NodeID, + update: impl FnOnce(&mut Node), + ) -> bool { + let Some(node) = self + .module_mut(module) + .and_then(|module| module.node_mut(node)) + else { + return false; + }; + update(node); + true + } + + fn set_node_string( + &mut self, + module: ModuleHandle, + node: NodeID, + value: &str, + update: impl FnOnce(&mut Node, Interned), + ) -> bool { + let Some(module) = self.module_mut(module) else { + return false; + }; + let Some((node, interned)) = module.intern_for_node(node, value) else { + return false; + }; + update(&mut module.nodes[node as usize], interned); + true + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn facts_builder_new() -> *mut FactsBuilder { + Box::into_raw(Box::new(FactsBuilder::new())) +} + +#[unsafe(no_mangle)] +pub extern "C" fn facts_builder_free(builder: *mut FactsBuilder) { + if !builder.is_null() { + unsafe { + drop(Box::from_raw(builder)); + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn facts_builder_add_module( + builder: *mut FactsBuilder, + hint: usize, +) -> ModuleHandle { + unsafe { builder.as_mut() }.map_or(INVALID_ID, |builder| builder.add_module(hint)) +} + +#[unsafe(no_mangle)] +pub extern "C" fn facts_builder_add_node( + builder: *mut FactsBuilder, + module: ModuleHandle, + ty: NodeType, +) -> NodeID { + unsafe { builder.as_mut() } + .and_then(|builder| builder.add_node(module, ty)) + .unwrap_or(INVALID_ID) +} + +#[unsafe(no_mangle)] +pub extern "C" fn facts_builder_add_edge( + builder: *mut FactsBuilder, + module: ModuleHandle, + src: NodeID, + dst: NodeID, + kind: EdgeKind, +) -> bool { + unsafe { builder.as_mut() }.is_some_and(|builder| builder.add_edge(module, src, dst, kind)) +} + +macro_rules! ffi_node_setter { + ($ffi:ident, $method:ident, $ty:ty) => { + #[unsafe(no_mangle)] + pub extern "C" fn $ffi( + builder: *mut FactsBuilder, + module: ModuleHandle, + node: NodeID, + value: $ty, + ) -> bool { + unsafe { builder.as_mut() }.is_some_and(|builder| builder.$method(module, node, value)) + } + }; +} + +macro_rules! ffi_node_string_setter { + ($ffi:ident, $method:ident) => { + #[unsafe(no_mangle)] + pub extern "C" fn $ffi( + builder: *mut FactsBuilder, + module: ModuleHandle, + node: NodeID, + ptr: *const u8, + len: usize, + ) -> bool { + let Some(builder) = (unsafe { builder.as_mut() }) else { + return false; + }; + let Some(value) = (unsafe { as_str(ptr, len) }) else { + return false; + }; + builder.$method(module, node, value) + } + }; +} + +ffi_node_setter!(facts_builder_set_node_idx, set_node_idx, u32); +ffi_node_string_setter!(facts_builder_set_node_name, set_node_name); +ffi_node_string_setter!(facts_builder_set_node_opcode, set_node_opcode); +ffi_node_setter!(facts_builder_set_node_linkage, set_node_linkage, Linkage); +ffi_node_setter!( + facts_builder_set_node_call_type, + set_node_call_type, + CallType +); +ffi_node_string_setter!(facts_builder_set_node_source_file, set_node_source_file); +ffi_node_string_setter!(facts_builder_set_node_function_type, set_node_function_type); +ffi_node_setter!( + facts_builder_set_node_address_taken, + set_node_address_taken, + bool +); + +#[unsafe(no_mangle)] +pub extern "C" fn facts_builder_set_node_source_loc( + builder: *mut FactsBuilder, + module: ModuleHandle, + node: NodeID, + line: u32, + col: u32, +) -> bool { + unsafe { builder.as_mut() } + .is_some_and(|builder| builder.set_node_source_loc(module, node, line, col)) +} diff --git a/resolve-facts/rs/src/interner.rs b/resolve-facts/rs/src/interner.rs new file mode 100644 index 000000000..44ca9b2d0 --- /dev/null +++ b/resolve-facts/rs/src/interner.rs @@ -0,0 +1,32 @@ +use std::collections::*; + +use crate::schema::*; + +#[derive(Default)] +pub struct Interner { + ids: HashMap, + bytes: Vec, +} + +impl Interner { + pub fn intern(&mut self, value: &str) -> Interned { + if let Some(&id) = self.ids.get(value) { + return id; + } + + let offset = u32::try_from(self.bytes.len()).expect("intern pool exceeds 4 GiB"); + let bytes = value.as_bytes(); + let len = u32::try_from(bytes.len()).expect("interned string exceeds 4 GiB"); + + self.bytes.extend_from_slice(&len.to_le_bytes()); + self.bytes.extend_from_slice(bytes); + + let id = Interned(offset); + self.ids.insert(value.to_owned(), id); + id + } + + pub fn bytes(&self) -> &[u8] { + &self.bytes + } +} diff --git a/resolve-facts/rs/src/lib.rs b/resolve-facts/rs/src/lib.rs new file mode 100644 index 000000000..66c734799 --- /dev/null +++ b/resolve-facts/rs/src/lib.rs @@ -0,0 +1,12 @@ +#[cfg(not(target_endian = "little"))] +compile_error!("the facts format currently requires a little-endian target"); + +mod builder; +mod interner; +mod schema; +mod utils; +mod writer; + +pub use builder::{FactsBuilder, ModuleHandle}; +pub use schema::*; +pub use writer::FactsBuf; diff --git a/resolve-facts/rs/src/schema.rs b/resolve-facts/rs/src/schema.rs new file mode 100644 index 000000000..2b565fbc2 --- /dev/null +++ b/resolve-facts/rs/src/schema.rs @@ -0,0 +1,541 @@ +// notes: +// - some fields are over-sized to pad the struct to alignment + +use std::mem::*; + +use bytemuck::*; + +pub const FORMAT_VERSION: u32 = 1; + +macro_rules! enum_from_u8 { + ($ty:ty, $($value:path),+ $(,)?) => { + impl TryFrom for $ty { + type Error = u8; + + fn try_from(value: u8) -> Result { + match value { + $(value if value == $value as u8 => Ok($value),)+ + value => Err(value), + } + } + } + }; +} + +// Node identifier: index in its parent modules Node array +pub type NodeID = u32; + +// Offset into modules interned string pool +#[repr(transparent)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Pod, Zeroable)] +pub struct Interned(pub u32); + +#[allow(dead_code)] // cbindgen +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NodeType { + Module = 0, + Function = 1, + Argument = 2, + BasicBlock = 3, + Instruction = 4, + GlobalVariable = 5, +} + +#[allow(dead_code)] // cbindgen +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Linkage { + Other = 0, + ExternalLinkage = 1, +} + +#[allow(dead_code)] // cbindgen +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CallType { + Direct = 0, + Indirect = 1, +} + +enum_from_u8!( + NodeType, + NodeType::Module, + NodeType::Function, + NodeType::Argument, + NodeType::BasicBlock, + NodeType::Instruction, + NodeType::GlobalVariable, +); +enum_from_u8!(Linkage, Linkage::Other, Linkage::ExternalLinkage); +enum_from_u8!(CallType, CallType::Direct, CallType::Indirect); + +// positions in Node.meta indicating which properties are present. +pub const P_IDX: u32 = 1 << 0; +pub const P_NAME: u32 = 1 << 1; +pub const P_OPCODE: u32 = 1 << 2; +pub const P_LINKAGE: u32 = 1 << 3; +pub const P_CALL_TYPE: u32 = 1 << 4; +pub const P_SOURCE_LOC: u32 = 1 << 5; +pub const P_SOURCE_FILE: u32 = 1 << 6; +pub const P_FUNCTION_TYPE: u32 = 1 << 7; +pub const P_ADDRESS_TAKEN: u32 = 1 << 8; + +#[allow(dead_code)] // cbindgen +pub const PRESENT_MASK: u32 = u16::MAX as u32; +pub const NODE_TYPE_SHIFT: u32 = 16; +#[allow(dead_code)] // cbindgen +pub const NODE_TYPE_MASK: u32 = 0xff << NODE_TYPE_SHIFT; +pub const LINKAGE_SHIFT: u32 = 24; +pub const LINKAGE_MASK: u32 = 0x0f << LINKAGE_SHIFT; +pub const CALL_TYPE_SHIFT: u32 = 28; +pub const CALL_TYPE_MASK: u32 = 0x0f << CALL_TYPE_SHIFT; + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Pod, Zeroable)] +pub struct Node { + pub meta: u32, + pub idx: u32, + pub name: Interned, + pub opcode: Interned, + pub source_line: u32, + pub source_col: u32, + pub source_file: Interned, + pub function_type: Interned, +} + +#[allow(dead_code)] // reader +impl Node { + pub const fn new(ty: NodeType) -> Self { + Self { + meta: (ty as u32) << NODE_TYPE_SHIFT, + idx: 0, + name: Interned(0), + opcode: Interned(0), + source_line: 0, + source_col: 0, + source_file: Interned(0), + function_type: Interned(0), + } + } + + pub const fn present(&self) -> u16 { + (self.meta & PRESENT_MASK) as u16 + } + + pub const fn has(&self, property: u32) -> bool { + self.meta & property != 0 + } + + pub const fn node_type_raw(&self) -> u8 { + ((self.meta & NODE_TYPE_MASK) >> NODE_TYPE_SHIFT) as u8 + } + + pub const fn linkage_raw(&self) -> Option { + if self.has(P_LINKAGE) { + Some(((self.meta & LINKAGE_MASK) >> LINKAGE_SHIFT) as u8) + } else { + None + } + } + + pub const fn call_type_raw(&self) -> Option { + if self.has(P_CALL_TYPE) { + Some(((self.meta & CALL_TYPE_MASK) >> CALL_TYPE_SHIFT) as u8) + } else { + None + } + } + + pub fn node_type(&self) -> Result { + self.node_type_raw().try_into() + } + + pub fn linkage(&self) -> Result, u8> { + self.linkage_raw().map(TryInto::try_into).transpose() + } + + pub fn call_type(&self) -> Result, u8> { + self.call_type_raw().map(TryInto::try_into).transpose() + } + + pub const fn idx(&self) -> Option { + if self.has(P_IDX) { + Some(self.idx) + } else { + None + } + } + + pub const fn source_loc(&self) -> Option<(u32, u32)> { + if self.has(P_SOURCE_LOC) { + Some((self.source_line, self.source_col)) + } else { + None + } + } + + pub const fn address_taken(&self) -> bool { + self.has(P_ADDRESS_TAKEN) + } + + pub fn set_present(&mut self, property: u32) { + self.meta |= property; + } + + pub fn set_linkage(&mut self, linkage: Linkage) { + self.meta = (self.meta & !LINKAGE_MASK) | ((linkage as u32) << LINKAGE_SHIFT) | P_LINKAGE; + } + + pub fn set_call_type(&mut self, call_type: CallType) { + self.meta = + (self.meta & !CALL_TYPE_MASK) | ((call_type as u32) << CALL_TYPE_SHIFT) | P_CALL_TYPE; + } +} + +#[allow(dead_code)] // cbindgen +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EdgeKind { + Calls = 0, + Contains = 1, + DataFlowTo = 2, + References = 3, + EntryPoint = 4, + ControlFlowTo = 5, +} + +enum_from_u8!( + EdgeKind, + EdgeKind::Calls, + EdgeKind::Contains, + EdgeKind::DataFlowTo, + EdgeKind::References, + EdgeKind::EntryPoint, + EdgeKind::ControlFlowTo, +); + +// A unique (src, dst) pair within a module. Edge arrays are sorted. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Pod, Zeroable)] +pub struct Edge { + pub src: NodeID, + pub dst: NodeID, + pub kinds: u32, +} + +#[allow(dead_code)] // reader +impl Edge { + pub const fn has_kind(&self, kind: EdgeKind) -> bool { + self.kinds & (1 << kind as u8) != 0 + } + + pub fn kinds(&self) -> impl Iterator + '_ { + [ + EdgeKind::Calls, + EdgeKind::Contains, + EdgeKind::DataFlowTo, + EdgeKind::References, + EdgeKind::EntryPoint, + EdgeKind::ControlFlowTo, + ] + .into_iter() + .filter(|kind| self.has_kind(*kind)) + } +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Pod, Zeroable)] +pub struct ModuleHeader { + pub version: u32, + pub node_count: u32, + pub edge_count: u32, + pub string_pool_len: u32, +} + +#[allow(dead_code)] // reader +impl ModuleHeader { + pub fn byte_len(&self) -> Option { + let nodes = usize::try_from(self.node_count) + .ok()? + .checked_mul(size_of::())?; + let edges = usize::try_from(self.edge_count) + .ok()? + .checked_mul(size_of::())?; + let strings = usize::try_from(self.string_pool_len).ok()?; + + size_of::() + .checked_add(nodes)? + .checked_add(edges)? + .checked_add(strings) + } +} + +// "View" API: + +#[derive(Clone, Copy, Debug)] +#[allow(dead_code)] // reader +pub struct ModuleRef<'a> { + bytes: &'a [u8], +} + +#[derive(Clone, Copy, Debug)] +pub struct NodeRef<'a> { + module: ModuleRef<'a>, + id: NodeID, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[allow(dead_code)] // reader +pub enum ViewError { + Misaligned, + Truncated, + UnsupportedVersion, + UnalignedModuleLength, +} + +impl std::fmt::Display for ViewError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Misaligned => f.write_str("facts bytes are not 4-byte aligned"), + Self::Truncated => f.write_str("facts module is truncated or has an invalid length"), + Self::UnsupportedVersion => f.write_str("facts module has an unsupported version"), + Self::UnalignedModuleLength => { + f.write_str("facts module length is not a multiple of 4 bytes") + } + } + } +} + +impl std::error::Error for ViewError {} + +#[allow(dead_code)] // reader +impl<'a> ModuleRef<'a> { + // Borrows the first complete module in "bytes" and returns the unconsumed suffix + pub fn from_prefix(bytes: &'a [u8]) -> Result<(Self, &'a [u8]), ViewError> { + if bytes.len() < size_of::() { + return Err(ViewError::Truncated); + } + if bytes.as_ptr().align_offset(align_of::()) != 0 { + return Err(ViewError::Misaligned); + } + + let header = bytemuck::from_bytes::(&bytes[..size_of::()]); + if header.version != FORMAT_VERSION { + return Err(ViewError::UnsupportedVersion); + } + + let module_len = header.byte_len().ok_or(ViewError::Truncated)?; + if module_len % align_of::() != 0 { + return Err(ViewError::UnalignedModuleLength); + } + if bytes.len() < module_len { + return Err(ViewError::Truncated); + } + + let (module, rest) = bytes.split_at(module_len); + Ok((Self { bytes: module }, rest)) + } + + pub fn header(&self) -> &'a ModuleHeader { + bytemuck::from_bytes(&self.bytes[..size_of::()]) + } + + pub const fn as_bytes(&self) -> &'a [u8] { + self.bytes + } + + pub fn nodes(&self) -> &'a [Node] { + let header = self.header(); + let start = size_of::(); + let end = start + header.node_count as usize * size_of::(); + bytemuck::cast_slice(&self.bytes[start..end]) + } + + pub fn edges(&self) -> &'a [Edge] { + let header = self.header(); + let nodes_len = header.node_count as usize * size_of::(); + let start = size_of::() + nodes_len; + let end = start + header.edge_count as usize * size_of::(); + bytemuck::cast_slice(&self.bytes[start..end]) + } + + pub fn string_pool(&self) -> &'a [u8] { + let header = self.header(); + let nodes_len = header.node_count as usize * size_of::(); + let edges_len = header.edge_count as usize * size_of::(); + let start = size_of::() + nodes_len + edges_len; + &self.bytes[start..start + header.string_pool_len as usize] + } + + // Resolves an interned ID without allocating or copying the string bytes. + pub fn string_bytes(&self, id: Interned) -> Option<&'a [u8]> { + let pool = self.string_pool(); + let offset = usize::try_from(id.0).ok()?; + let length_end = offset.checked_add(size_of::())?; + let length_bytes: [u8; 4] = pool.get(offset..length_end)?.try_into().ok()?; + let length = u32::from_le_bytes(length_bytes) as usize; + let value_end = length_end.checked_add(length)?; + pool.get(length_end..value_end) + } + + pub fn string(&self, id: Interned) -> Option<&'a str> { + std::str::from_utf8(self.string_bytes(id)?).ok() + } + + pub fn node(&self, id: NodeID) -> Option<&'a Node> { + self.nodes().get(id as usize) + } + + pub fn node_ref(&self, id: NodeID) -> Option> { + self.node(id)?; + Some(NodeRef { module: *self, id }) + } + + pub fn node_refs(&self) -> NodeIter<'a> { + NodeIter { + module: *self, + next: 0, + } + } +} + +impl<'a> NodeRef<'a> { + pub const fn id(self) -> NodeID { + self.id + } + + pub fn raw(self) -> &'a Node { + &self.module.nodes()[self.id as usize] + } + + pub fn node_type(self) -> Result { + self.raw().node_type() + } + + pub fn idx(self) -> Option { + self.raw().idx() + } + + pub fn name(self) -> Option<&'a str> { + self.string(P_NAME, self.raw().name) + } + + pub fn opcode(self) -> Option<&'a str> { + self.string(P_OPCODE, self.raw().opcode) + } + + pub fn linkage(self) -> Result, u8> { + self.raw().linkage() + } + + pub fn call_type(self) -> Result, u8> { + self.raw().call_type() + } + + pub fn source_loc(self) -> Option<(u32, u32)> { + self.raw().source_loc() + } + + pub fn source_file(self) -> Option<&'a str> { + self.string(P_SOURCE_FILE, self.raw().source_file) + } + + pub fn function_type(self) -> Option<&'a str> { + self.string(P_FUNCTION_TYPE, self.raw().function_type) + } + + pub fn address_taken(self) -> bool { + self.raw().address_taken() + } + + fn string(self, property: u32, id: Interned) -> Option<&'a str> { + self.raw().has(property).then(|| self.module.string(id))? + } +} + +#[derive(Clone, Debug)] +pub struct NodeIter<'a> { + module: ModuleRef<'a>, + next: NodeID, +} + +impl<'a> Iterator for NodeIter<'a> { + type Item = NodeRef<'a>; + + fn next(&mut self) -> Option { + let node = self.module.node_ref(self.next)?; + self.next += 1; + Some(node) + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = self.module.nodes().len() - self.next as usize; + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for NodeIter<'_> {} + +// A non-owning view over a complete concatenation of modules +#[derive(Clone, Copy, Debug)] +#[allow(dead_code)] // reader +pub struct FactsRef<'a> { + bytes: &'a [u8], +} + +#[allow(dead_code)] // reader +impl<'a> FactsRef<'a> { + pub const fn new(bytes: &'a [u8]) -> Self { + Self { bytes } + } + + pub const fn modules(self) -> ModuleIter<'a> { + ModuleIter { + remaining: self.bytes, + } + } + + pub const fn as_bytes(self) -> &'a [u8] { + self.bytes + } +} + +#[allow(dead_code)] // reader +pub struct ModuleIter<'a> { + remaining: &'a [u8], +} + +impl<'a> Iterator for ModuleIter<'a> { + type Item = Result, ViewError>; + + fn next(&mut self) -> Option { + if self.remaining.is_empty() { + return None; + } + + match ModuleRef::from_prefix(self.remaining) { + Ok((module, rest)) => { + self.remaining = rest; + Some(Ok(module)) + } + Err(error) => { + self.remaining = &[]; + Some(Err(error)) + } + } + } +} + +/// cbindgen:ignore +#[allow(dead_code)] +const LAYOUT_ASSERTIONS: () = { + assert!(size_of::() == 4); + assert!(align_of::() == 4); + assert!(size_of::() == 16); + assert!(align_of::() == 4); + assert!(size_of::() == 32); + assert!(align_of::() == 4); + assert!(size_of::() == 12); + assert!(align_of::() == 4); +}; diff --git a/resolve-facts/rs/src/utils.rs b/resolve-facts/rs/src/utils.rs new file mode 100644 index 000000000..9aba5a8b3 --- /dev/null +++ b/resolve-facts/rs/src/utils.rs @@ -0,0 +1,6 @@ +pub unsafe fn as_str<'a>(ptr: *const u8, len: usize) -> Option<&'a str> { + if ptr.is_null() { + return None; + } + std::str::from_utf8(unsafe { std::slice::from_raw_parts(ptr, len) }).ok() +} diff --git a/resolve-facts/rs/src/writer.rs b/resolve-facts/rs/src/writer.rs new file mode 100644 index 000000000..dff8ba7ae --- /dev/null +++ b/resolve-facts/rs/src/writer.rs @@ -0,0 +1,56 @@ +use crate::builder::*; +use crate::schema::*; + +#[derive(Debug, Default)] +pub struct FactsBuf(Vec); + +impl FactsBuf { + pub(crate) fn from_words(words: Vec) -> Self { + Self(words) + } + + pub fn as_bytes(&self) -> &[u8] { + bytemuck::cast_slice(&self.0) + } + + pub fn view(&self) -> FactsRef<'_> { + FactsRef::new(self.as_bytes()) + } + + pub fn len(&self) -> usize { + self.as_bytes().len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn facts_buf_len(b: *const FactsBuf) -> usize { + unsafe { b.as_ref() }.map_or(0, |buf| buf.as_bytes().len()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn facts_buf_data(b: *const FactsBuf) -> *const u8 { + unsafe { b.as_ref() }.map_or(std::ptr::null(), |buf| buf.as_bytes().as_ptr()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn facts_buf_free(b: *mut FactsBuf) { + if !b.is_null() { + unsafe { + drop(Box::from_raw(b)); + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn facts_builder_freeze(builder: *mut FactsBuilder) -> *mut FactsBuf { + if builder.is_null() { + return std::ptr::null_mut(); + } + + let builder = unsafe { Box::from_raw(builder) }; + Box::into_raw(Box::new(builder.freeze())) +} diff --git a/scripts/install-deps-ci.sh b/scripts/install-deps-ci.sh index a55355365..427a9ac1f 100755 --- a/scripts/install-deps-ci.sh +++ b/scripts/install-deps-ci.sh @@ -62,4 +62,6 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ | sh -s -- -y --default-toolchain nightly source "$HOME/.cargo/env" +echo "[*] Installing cbindgen" +cargo install cbindgen --version 0.29.0 --locked echo " All dependencies installed successfully." From 282d364436228132acdf32712ca604d878a08f6c Mon Sep 17 00:00:00 2001 From: Ryan Zmuda Date: Thu, 20 Aug 2026 10:04:15 -0400 Subject: [PATCH 02/16] fix: build facts-rs during default cmake build --- resolve-facts/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resolve-facts/CMakeLists.txt b/resolve-facts/CMakeLists.txt index a0fc96deb..abfb69336 100644 --- a/resolve-facts/CMakeLists.txt +++ b/resolve-facts/CMakeLists.txt @@ -39,7 +39,7 @@ add_custom_command( COMMENT "Building facts-rs and generating its C++ ABI header" VERBATIM ) -add_custom_target(facts_rs_build DEPENDS "${FACTS_RS_STATICLIB}" "${FACTS_RS_HEADER}") +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 From 3ba7a06e716c7acb03042ac5393f5994b3217dda Mon Sep 17 00:00:00 2001 From: Ryan Zmuda Date: Tue, 18 Aug 2026 12:00:47 -0400 Subject: [PATCH 03/16] facts: add flat binary LLVM producer --- resolve-facts/CMakeLists.txt | 18 +- resolve-facts/Config.cmake.in | 1 + .../resolve_facts_llvm/BinaryLLVMFacts.hpp | 257 ++++++++++++++++++ .../resolve_facts_llvm/binary_facts_llvm.hpp | 38 +++ .../resolve_facts_llvm/binary_facts_llvm.cpp | 164 +++++++++++ 5 files changed, 474 insertions(+), 4 deletions(-) create mode 100644 resolve-facts/include/resolve_facts_llvm/BinaryLLVMFacts.hpp create mode 100644 resolve-facts/include/resolve_facts_llvm/binary_facts_llvm.hpp create mode 100644 resolve-facts/libs/resolve_facts_llvm/binary_facts_llvm.cpp diff --git a/resolve-facts/CMakeLists.txt b/resolve-facts/CMakeLists.txt index abfb69336..bba8bbc86 100644 --- a/resolve-facts/CMakeLists.txt +++ b/resolve-facts/CMakeLists.txt @@ -77,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) @@ -108,12 +108,22 @@ 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 + libs/resolve_facts_llvm/resolve_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 + resolve_facts + 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) diff --git a/resolve-facts/Config.cmake.in b/resolve-facts/Config.cmake.in index 9ca79c112..e0304d245 100644 --- a/resolve-facts/Config.cmake.in +++ b/resolve-facts/Config.cmake.in @@ -2,6 +2,7 @@ include(CMakeFindDependencyMacro) find_dependency(glaze) +find_dependency(Threads) if(NOT TARGET facts_rs) add_library(facts_rs STATIC IMPORTED) 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..40595e4a5 --- /dev/null +++ b/resolve-facts/include/resolve_facts_llvm/BinaryLLVMFacts.hpp @@ -0,0 +1,257 @@ +/* + * 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 + +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; + + 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; + + 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/binary_facts_llvm.hpp b/resolve-facts/include/resolve_facts_llvm/binary_facts_llvm.hpp new file mode 100644 index 000000000..4a9236cbd --- /dev/null +++ b/resolve-facts/include/resolve_facts_llvm/binary_facts_llvm.hpp @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Riverside Research. + * LGPL-3; See LICENSE.txt in the repo root for details. + */ + +#include "resolve_facts_llvm/BinaryLLVMFacts.hpp" + +#include "llvm/ADT/SmallString.h" +#include "llvm/ADT/SmallVector.h" +#include "llvm/IR/BasicBlock.h" +#include "llvm/IR/CFG.h" +#include "llvm/IR/Constants.h" +#include "llvm/IR/DebugInfoMetadata.h" +#include "llvm/IR/Function.h" +#include "llvm/IR/GlobalVariable.h" +#include "llvm/IR/Instruction.h" +#include "llvm/IR/Instructions.h" +#include "llvm/IR/LLVMContext.h" +#include "llvm/IR/Module.h" +#include "llvm/IR/PassManager.h" +#include "llvm/Passes/PassBuilder.h" +#include "llvm/Passes/PassPlugin.h" +#include "llvm/Transforms/Utils/ModuleUtils.h" + +using namespace llvm; + +namespace resolve { +std::string binaryTypeToString(const Type &type); + +void getBinaryGlobalFacts(BinaryLLVMFacts &facts, GlobalVariable &G); + +void getBinaryFunctionFacts(BinaryLLVMFacts &facts, Function &F); + +void getBinaryModuleFacts(BinaryLLVMFacts &facts, Module &M); + +// Embed the accumulated facts into custom ELF sections. +void embedBinaryFacts(Module &M, ArrayRef facts); +} // namespace resolve 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 +#include +#include +#include +#include + +#include "llvm/Support/Compression.h" + +using namespace llvm; +using namespace facts_rs; + +std::string resolve::binaryTypeToString(const Type &type) { + std::string str; + llvm::raw_string_ostream out(str); + type.print(out); + return str; +} + +void resolve::getBinaryGlobalFacts(BinaryLLVMFacts &facts, GlobalVariable &G) { + facts.addNode(G); + facts.setName(G, G.getName()); + facts.setLinkage(G, G.hasExternalLinkage() ? Linkage::ExternalLinkage + : Linkage::Other); +} + +static std::string getFunctionNameFromDebugInfo(Function &F) { + // Each function may have a DISubprogram attached + if (auto *SP = F.getSubprogram()) { + if (auto *File = SP->getFile()) { + return (File->getDirectory() + "/" + File->getFilename()).str(); + } + } + return ""; +} + +void resolve::getBinaryFunctionFacts(BinaryLLVMFacts &facts, Function &F) { + facts.addNode(F); + facts.setName(F, F.getName()); + facts.setLinkage(F, F.hasExternalLinkage() ? Linkage::ExternalLinkage + : Linkage::Other); + facts.setFunctionType(F, binaryTypeToString(*F.getFunctionType())); + auto name = getFunctionNameFromDebugInfo(F); + if (!name.empty()) { + facts.setSourceFile(F, name); + } + if (F.hasAddressTaken()) { + facts.setAddressTaken(F); + } + + if (F.isDeclaration()) + return; + + facts.addEdge(F, F.getEntryBlock(), EdgeKind::EntryPoint); + + for (Argument &A : F.args()) { + facts.addEdge(F, A, EdgeKind::Contains); + facts.setIdx(A, A.getArgNo()); + } + + for (BasicBlock &BB : F) { + facts.addEdge(F, BB, EdgeKind::Contains); + facts.setIdx(BB, BinaryLLVMFacts::getIndexInParent(BB)); + if (BB.hasName()) { + facts.setName(BB, BB.getName()); + } + + // Control flow Edges + for (BasicBlock *Succ : successors(&BB)) { + facts.addEdge(BB, *Succ, EdgeKind::ControlFlowTo); + } + + for (Instruction &I : BB) { + facts.addEdge(BB, I, EdgeKind::Contains); + facts.setOpcode(I, I.getOpcodeName()); + if (auto dbgLoc = I.getDebugLoc()) { + facts.setSourceLoc(I, dbgLoc.getLine(), dbgLoc.getCol()); + } + + // Data–flow edges: from each operand (if an instruction) to I. + for (Value *op : I.operands()) { + if (Instruction *opI = dyn_cast(op)) { + facts.addEdge(*opI, I, EdgeKind::DataFlowTo); + } else if (Argument *opA = dyn_cast(op)) { + facts.addEdge(*opA, I, EdgeKind::DataFlowTo); + } else if (GlobalVariable *opG = dyn_cast(op)) { + facts.addEdge(I, *opG, EdgeKind::References); + } else if (Function *opF = dyn_cast(op)) { + facts.addEdge(I, *opF, EdgeKind::References); + } + } + + // Call edge: record call relationship at the instruction level only. + if (auto *CB = dyn_cast(&I)) { + CallType ct; + if (Function *Callee = CB->getCalledFunction()) { + facts.addEdge(I, *Callee, EdgeKind::Calls); + ct = CallType::Direct; + } else { + // Indirect call + ct = CallType::Indirect; + } + + facts.setCallType(I, ct); + facts.setFunctionType(I, binaryTypeToString(*CB->getFunctionType())); + } + } + } +} + +void resolve::getBinaryModuleFacts(BinaryLLVMFacts &facts, Module &M) { + facts.setSourceFile(M, M.getSourceFileName()); + + for (GlobalVariable &G : M.globals()) { + facts.addEdge(M, G, EdgeKind::Contains); + + getBinaryGlobalFacts(facts, G); + } + + for (Function &F : M) { + facts.addEdge(M, F, EdgeKind::Contains); + + getBinaryFunctionFacts(facts, F); + } +} + +// Embed the accumulated facts into custom ELF sections. +void resolve::embedBinaryFacts(Module &M, ArrayRef facts) { + LLVMContext &C = M.getContext(); + auto embedFactsSection = [&](StringRef sectionName, + ArrayRef inputData) { + SmallVector compressedFacts; + + if (std::getenv("RESOLVE_IGNORE_COMPRESSION")) { + compressedFacts = SmallVector(inputData); + } else { + compression::Params params(compression::Format::Zstd); + compression::compress(params, inputData, compressedFacts); + } + + // errs() << "Embedding facts for " << sectionName << " with original size " + // << facts.size() << " and compressed size " << compressedFacts.size() << + // "\n"; + + Constant *dataArr = ConstantDataArray::get(C, compressedFacts); + GlobalVariable *gv = + new GlobalVariable(M, dataArr->getType(), + /*isConstant=*/true, GlobalValue::InternalLinkage, + dataArr, "resolve" + std::string(sectionName)); + gv->setAlignment(Align()); + gv->setSection(sectionName); + appendToCompilerUsed(M, {gv}); + }; + + embedFactsSection(".facts", facts); +} From 416438efb6017b9745d80db34aff20f552c3c29d Mon Sep 17 00:00:00 2001 From: Ryan Zmuda Date: Tue, 18 Aug 2026 12:01:45 -0400 Subject: [PATCH 04/16] facts: add multi-file binary reader --- resolve-facts/rs/Cargo.lock | 157 +++++++++ resolve-facts/rs/Cargo.toml | 5 + resolve-facts/rs/cbindgen.toml | 4 + resolve-facts/rs/src/ffi.rs | 149 +++++++++ resolve-facts/rs/src/lib.rs | 3 + resolve-facts/rs/src/reader.rs | 576 +++++++++++++++++++++++++++++++++ 6 files changed, 894 insertions(+) create mode 100644 resolve-facts/rs/src/ffi.rs create mode 100644 resolve-facts/rs/src/reader.rs diff --git a/resolve-facts/rs/Cargo.lock b/resolve-facts/rs/Cargo.lock index f95a5febc..fb2664945 100644 --- a/resolve-facts/rs/Cargo.lock +++ b/resolve-facts/rs/Cargo.lock @@ -22,13 +22,130 @@ dependencies = [ "syn", ] +[[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 = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[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 = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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 = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[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 = [ + "crc32fast", + "hashbrown", + "indexmap", + "memchr", +] + +[[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" @@ -47,6 +164,18 @@ 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 = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "syn" version = "3.0.3" @@ -63,3 +192,31 @@ name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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-facts/rs/Cargo.toml b/resolve-facts/rs/Cargo.toml index 725335a9f..0cfdb0b7d 100644 --- a/resolve-facts/rs/Cargo.toml +++ b/resolve-facts/rs/Cargo.toml @@ -8,3 +8,8 @@ crate-type = ["rlib", "staticlib", "cdylib"] [dependencies] bytemuck = { version = "1", features = ["derive"] } +object = { version = "0.39.1", default-features = false, features = ["read_core", "elf", "std"] } +zstd = "0.13.3" + +[dev-dependencies] +object = { version = "0.39.1", default-features = false, features = ["write"] } diff --git a/resolve-facts/rs/cbindgen.toml b/resolve-facts/rs/cbindgen.toml index babadc1ac..e027f9d10 100644 --- a/resolve-facts/rs/cbindgen.toml +++ b/resolve-facts/rs/cbindgen.toml @@ -18,4 +18,8 @@ crates = ["facts-rs"] include = [ "Node", "Edge", + "FactsPath", + "FactsReadResult", + "FactsModuleCursor", + "FactsModuleView", ] diff --git a/resolve-facts/rs/src/ffi.rs b/resolve-facts/rs/src/ffi.rs new file mode 100644 index 000000000..cbeebe69a --- /dev/null +++ b/resolve-facts/rs/src/ffi.rs @@ -0,0 +1,149 @@ +use std::path::*; + +use crate::schema::*; +use crate::utils::*; +use crate::writer::*; + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +pub struct FactsPath { + pub data: *const u8, + pub len: usize, +} + +pub struct FactsReadError(String); + +#[repr(C)] +#[derive(Debug)] +pub struct FactsReadResult { + pub facts: *mut FactsBuf, + pub error: *mut FactsReadError, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +pub struct FactsModuleCursor { + pub byte_offset: usize, + pub module_index: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug)] +pub struct FactsModuleView { + pub module_index: u32, + pub nodes: *const Node, + pub node_count: usize, + pub edges: *const Edge, + pub edge_count: usize, + pub string_pool: *const u8, + pub string_pool_len: usize, +} + +impl FactsReadResult { + fn success(facts: FactsBuf) -> Self { + Self { + facts: Box::into_raw(Box::new(facts)), + error: std::ptr::null_mut(), + } + } + + fn error(message: impl Into) -> Self { + Self { + facts: std::ptr::null_mut(), + error: Box::into_raw(Box::new(FactsReadError(message.into()))), + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn facts_read_files(paths: *const FactsPath, len: usize) -> FactsReadResult { + let paths = if len == 0 { + &[] + } else if paths.is_null() { + return FactsReadResult::error("null facts path array"); + } else { + unsafe { std::slice::from_raw_parts(paths, len) } + }; + + let mut owned = Vec::with_capacity(paths.len()); + for path in paths { + let Some(path) = (unsafe { as_str(path.data, path.len) }) else { + return FactsReadResult::error("facts paths must be valid UTF-8"); + }; + owned.push(PathBuf::from(path)); + } + + match FactsBuf::read_files(&owned) { + Ok(facts) => FactsReadResult::success(facts), + Err(error) => FactsReadResult::error(error.to_string()), + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn facts_read_error_len(error: *const FactsReadError) -> usize { + unsafe { error.as_ref() }.map_or(0, |error| error.0.len()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn facts_read_error_data(error: *const FactsReadError) -> *const u8 { + unsafe { error.as_ref() }.map_or(std::ptr::null(), |error| error.0.as_ptr()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn facts_read_error_free(error: *mut FactsReadError) { + if !error.is_null() { + unsafe { + drop(Box::from_raw(error)); + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn facts_module_next( + facts: *const FactsBuf, + cursor: *mut FactsModuleCursor, + output: *mut FactsModuleView, +) -> bool { + let Some(facts) = (unsafe { facts.as_ref() }) else { + return false; + }; + let Some(cursor) = (unsafe { cursor.as_mut() }) else { + return false; + }; + let Some(output) = (unsafe { output.as_mut() }) else { + return false; + }; + + let bytes = facts.as_bytes(); + if cursor.byte_offset == bytes.len() { + return false; + } + let Some(remaining) = bytes.get(cursor.byte_offset..) else { + return false; + }; + let Ok((module, _)) = ModuleRef::from_prefix(remaining) else { + return false; + }; + let Some(next_offset) = cursor.byte_offset.checked_add(module.as_bytes().len()) else { + return false; + }; + let Some(next_index) = cursor.module_index.checked_add(1) else { + return false; + }; + + let nodes = module.nodes(); + let edges = module.edges(); + let string_pool = module.string_pool(); + *output = FactsModuleView { + module_index: cursor.module_index, + nodes: nodes.as_ptr(), + node_count: nodes.len(), + edges: edges.as_ptr(), + edge_count: edges.len(), + string_pool: string_pool.as_ptr(), + string_pool_len: string_pool.len(), + }; + cursor.byte_offset = next_offset; + cursor.module_index = next_index; + true +} diff --git a/resolve-facts/rs/src/lib.rs b/resolve-facts/rs/src/lib.rs index 66c734799..6488d8636 100644 --- a/resolve-facts/rs/src/lib.rs +++ b/resolve-facts/rs/src/lib.rs @@ -2,11 +2,14 @@ compile_error!("the facts format currently requires a little-endian target"); mod builder; +mod ffi; mod interner; +mod reader; mod schema; mod utils; mod writer; pub use builder::{FactsBuilder, ModuleHandle}; +pub use reader::*; pub use schema::*; pub use writer::FactsBuf; diff --git a/resolve-facts/rs/src/reader.rs b/resolve-facts/rs/src/reader.rs new file mode 100644 index 000000000..94aba14fc --- /dev/null +++ b/resolve-facts/rs/src/reader.rs @@ -0,0 +1,576 @@ +use std::fs::read; +use std::io::Read; +use std::mem::*; +use std::path::{Path, PathBuf}; + +use object::{Object, ObjectSection}; + +use crate::schema::*; +use crate::writer::*; + +const FACTS_SECTION: &str = ".facts"; +const ZSTD_MAGIC: u32 = 0xfd2f_b528; +const ZSTD_SKIPPABLE_MAGIC: u32 = 0x184d_2a50; +const KNOWN_PROPERTIES: u32 = P_IDX + | P_NAME + | P_OPCODE + | P_LINKAGE + | P_CALL_TYPE + | P_SOURCE_LOC + | P_SOURCE_FILE + | P_FUNCTION_TYPE + | P_ADDRESS_TAKEN; +const KNOWN_EDGE_KINDS: u32 = (1 << EdgeKind::Calls as u8) + | (1 << EdgeKind::Contains as u8) + | (1 << EdgeKind::DataFlowTo as u8) + | (1 << EdgeKind::References as u8) + | (1 << EdgeKind::EntryPoint as u8) + | (1 << EdgeKind::ControlFlowTo as u8); + +#[derive(Debug)] +pub enum ReadError { + Io { + path: PathBuf, + source: std::io::Error, + }, + Object { + path: PathBuf, + source: object::Error, + }, + MissingFactsSection { + path: PathBuf, + }, + Decompression { + path: PathBuf, + source: std::io::Error, + }, + UnalignedPayload { + path: PathBuf, + byte_len: usize, + }, + InvalidFacts { + path: PathBuf, + source: InvalidFacts, + }, +} + +impl std::fmt::Display for ReadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io { path, source } => write!(f, "could not read {}: {source}", path.display()), + Self::Object { path, source } => { + write!(f, "could not read ELF {}: {source}", path.display()) + } + Self::MissingFactsSection { path } => { + write!(f, "ELF {} has no {FACTS_SECTION} section", path.display()) + } + Self::Decompression { path, source } => { + write!( + f, + "could not decompress facts from {}: {source}", + path.display() + ) + } + Self::UnalignedPayload { path, byte_len } => write!( + f, + "facts from {} contain {byte_len} bytes, which is not divisible by 4", + path.display() + ), + Self::InvalidFacts { path, source } => { + write!(f, "invalid facts in {}: {source}", path.display()) + } + } + } +} + +impl std::error::Error for ReadError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io { source, .. } => Some(source), + Self::Object { source, .. } => Some(source), + Self::Decompression { source, .. } => Some(source), + Self::InvalidFacts { source, .. } => Some(source), + Self::MissingFactsSection { .. } | Self::UnalignedPayload { .. } => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InvalidFacts { + View { + module: usize, + byte_offset: usize, + source: ViewError, + }, + UnknownProperties { + module: usize, + node: NodeID, + bits: u32, + }, + InvalidNodeType { + module: usize, + node: NodeID, + value: u8, + }, + MissingModuleNode { + module: usize, + }, + InvalidModuleNode { + module: usize, + value: u8, + }, + InvalidLinkage { + module: usize, + node: NodeID, + value: u8, + }, + InvalidCallType { + module: usize, + node: NodeID, + value: u8, + }, + InvalidStringOffset { + module: usize, + node: NodeID, + property: &'static str, + offset: u32, + }, + InvalidUtf8 { + module: usize, + node: NodeID, + property: &'static str, + offset: u32, + }, + InvalidEdgeEndpoint { + module: usize, + edge: usize, + endpoint: NodeID, + node_count: u32, + }, + UnknownEdgeKinds { + module: usize, + edge: usize, + bits: u32, + }, + UnsortedEdges { + module: usize, + edge: usize, + }, +} + +impl std::fmt::Display for InvalidFacts { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::View { + module, + byte_offset, + source, + } => write!( + f, + "module {module} at byte {byte_offset} cannot be viewed: {source}" + ), + Self::UnknownProperties { module, node, bits } => write!( + f, + "module {module}, node {node} has unknown property bits {bits:#x}" + ), + Self::InvalidNodeType { + module, + node, + value, + } => write!( + f, + "module {module}, node {node} has invalid node type {value}" + ), + Self::MissingModuleNode { module } => { + write!(f, "module {module} has no module node at index 0") + } + Self::InvalidModuleNode { module, value } => write!( + f, + "module {module}, node 0 has node type {value}, not Module" + ), + Self::InvalidLinkage { + module, + node, + value, + } => write!( + f, + "module {module}, node {node} has invalid linkage {value}" + ), + Self::InvalidCallType { + module, + node, + value, + } => write!( + f, + "module {module}, node {node} has invalid call type {value}" + ), + Self::InvalidStringOffset { + module, + node, + property, + offset, + } => write!( + f, + "module {module}, node {node} has an invalid {property} string offset {offset}" + ), + Self::InvalidUtf8 { + module, + node, + property, + offset, + } => write!( + f, + "module {module}, node {node} has non-UTF-8 {property} at string offset {offset}" + ), + Self::InvalidEdgeEndpoint { + module, + edge, + endpoint, + node_count, + } => write!( + f, + "module {module}, edge {edge} refers to node {endpoint}, but the module has {node_count} nodes" + ), + Self::UnknownEdgeKinds { module, edge, bits } => write!( + f, + "module {module}, edge {edge} has unknown kind bits {bits:#x}" + ), + Self::UnsortedEdges { module, edge } => write!( + f, + "module {module}, edge {edge} is not strictly ordered after its predecessor" + ), + } + } +} + +impl std::error::Error for InvalidFacts { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::View { source, .. } => Some(source), + _ => None, + } + } +} + +impl FactsBuf { + pub fn read_file(path: impl AsRef) -> Result { + Self::read_files([path]) + } + + pub fn read_files(paths: I) -> Result + where + I: IntoIterator, + P: AsRef, + { + let mut words = Vec::new(); + for path in paths { + read_input(path.as_ref(), &mut words)?; + } + Ok(Self::from_words(words)) + } +} + +fn read_input(path: &Path, words: &mut Vec) -> Result<(), ReadError> { + let input = read(path).map_err(|source| ReadError::Io { + path: path.to_owned(), + source, + })?; + + let payload = if input.starts_with(b"\x7fELF") { + let object = object::File::parse(input.as_slice()).map_err(|source| ReadError::Object { + path: path.to_owned(), + source, + })?; + let section = object.section_by_name(FACTS_SECTION).ok_or_else(|| { + ReadError::MissingFactsSection { + path: path.to_owned(), + } + })?; + section.data().map_err(|source| ReadError::Object { + path: path.to_owned(), + source, + })? + } else { + input.as_slice() + }; + + let start = words.len(); + if is_zstd(payload) { + let decoder = zstd::stream::read::Decoder::new(payload).map_err(|source| { + ReadError::Decompression { + path: path.to_owned(), + source, + } + })?; + append_reader(decoder, words).map_err(|error| match error { + AppendError::Read(source) => ReadError::Decompression { + path: path.to_owned(), + source, + }, + AppendError::Unaligned(byte_len) => ReadError::UnalignedPayload { + path: path.to_owned(), + byte_len, + }, + })?; + } else { + append_bytes(payload, words).map_err(|byte_len| ReadError::UnalignedPayload { + path: path.to_owned(), + byte_len, + })?; + } + + let bytes = words_as_bytes(&words[start..]); + validate(bytes).map_err(|source| ReadError::InvalidFacts { + path: path.to_owned(), + source, + }) +} + +fn is_zstd(bytes: &[u8]) -> bool { + let Some(magic) = bytes + .get(..size_of::()) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_le_bytes) + else { + return false; + }; + + magic == ZSTD_MAGIC || magic & 0xffff_fff0 == ZSTD_SKIPPABLE_MAGIC +} + +fn append_bytes(bytes: &[u8], words: &mut Vec) -> Result<(), usize> { + if !bytes.len().is_multiple_of(size_of::()) { + return Err(bytes.len()); + } + + words.reserve(bytes.len() / size_of::()); + for chunk in bytes.chunks_exact(size_of::()) { + words.push(u32::from_le_bytes(chunk.try_into().unwrap())); + } + Ok(()) +} + +enum AppendError { + Read(std::io::Error), + Unaligned(usize), +} + +fn append_reader(mut reader: impl Read, words: &mut Vec) -> Result<(), AppendError> { + let mut buffer = [0; 64 * 1024]; + let mut pending = [0; size_of::()]; + let mut pending_len = 0; + let mut byte_len = 0; + + loop { + let read = reader.read(&mut buffer).map_err(AppendError::Read)?; + if read == 0 { + break; + } + byte_len += read; + + let mut bytes = &buffer[..read]; + if pending_len != 0 { + let needed = size_of::() - pending_len; + let copied = needed.min(bytes.len()); + pending[pending_len..pending_len + copied].copy_from_slice(&bytes[..copied]); + pending_len += copied; + bytes = &bytes[copied..]; + if pending_len != size_of::() { + continue; + } + words.push(u32::from_le_bytes(pending)); + } + + let mut chunks = bytes.chunks_exact(size_of::()); + words.extend( + chunks + .by_ref() + .map(|chunk| u32::from_le_bytes(chunk.try_into().unwrap())), + ); + let remainder = chunks.remainder(); + pending[..remainder.len()].copy_from_slice(remainder); + pending_len = remainder.len(); + } + + if pending_len == 0 { + Ok(()) + } else { + Err(AppendError::Unaligned(byte_len)) + } +} + +fn words_as_bytes(words: &[u32]) -> &[u8] { + bytemuck::cast_slice(words) +} + +fn validate(bytes: &[u8]) -> Result<(), InvalidFacts> { + let mut remaining = bytes; + let mut byte_offset = 0; + let mut module_index = 0; + + while !remaining.is_empty() { + let (module, rest) = + ModuleRef::from_prefix(remaining).map_err(|source| InvalidFacts::View { + module: module_index, + byte_offset, + source, + })?; + validate_module(module_index, module)?; + byte_offset += module.as_bytes().len(); + module_index += 1; + remaining = rest; + } + + Ok(()) +} + +fn validate_module(module_index: usize, module: ModuleRef<'_>) -> Result<(), InvalidFacts> { + let Some(module_node) = module.nodes().first() else { + return Err(InvalidFacts::MissingModuleNode { + module: module_index, + }); + }; + if module_node.node_type_raw() != NodeType::Module as u8 { + return Err(InvalidFacts::InvalidModuleNode { + module: module_index, + value: module_node.node_type_raw(), + }); + } + + for (node_index, node) in module.nodes().iter().enumerate() { + let node_index = node_index as NodeID; + let unknown = node.meta & PRESENT_MASK & !KNOWN_PROPERTIES; + if unknown != 0 { + return Err(InvalidFacts::UnknownProperties { + module: module_index, + node: node_index, + bits: unknown, + }); + } + if node.node_type_raw() > NodeType::GlobalVariable as u8 { + return Err(InvalidFacts::InvalidNodeType { + module: module_index, + node: node_index, + value: node.node_type_raw(), + }); + } + if let Some(value) = node.linkage_raw() + && value > Linkage::ExternalLinkage as u8 + { + return Err(InvalidFacts::InvalidLinkage { + module: module_index, + node: node_index, + value, + }); + } + if let Some(value) = node.call_type_raw() + && value > CallType::Indirect as u8 + { + return Err(InvalidFacts::InvalidCallType { + module: module_index, + node: node_index, + value, + }); + } + + validate_node_string( + module_index, + node_index, + node, + module, + P_NAME, + "name", + node.name, + )?; + validate_node_string( + module_index, + node_index, + node, + module, + P_OPCODE, + "opcode", + node.opcode, + )?; + validate_node_string( + module_index, + node_index, + node, + module, + P_SOURCE_FILE, + "source file", + node.source_file, + )?; + validate_node_string( + module_index, + node_index, + node, + module, + P_FUNCTION_TYPE, + "function type", + node.function_type, + )?; + } + + let node_count = module.header().node_count; + let mut previous = None; + for (edge_index, edge) in module.edges().iter().enumerate() { + for endpoint in [edge.src, edge.dst] { + if endpoint >= node_count { + return Err(InvalidFacts::InvalidEdgeEndpoint { + module: module_index, + edge: edge_index, + endpoint, + node_count, + }); + } + } + let unknown = edge.kinds & !KNOWN_EDGE_KINDS; + if unknown != 0 { + return Err(InvalidFacts::UnknownEdgeKinds { + module: module_index, + edge: edge_index, + bits: unknown, + }); + } + let current = (edge.src, edge.dst); + if previous.is_some_and(|previous| previous >= current) { + return Err(InvalidFacts::UnsortedEdges { + module: module_index, + edge: edge_index, + }); + } + previous = Some(current); + } + + Ok(()) +} + +fn validate_node_string( + module_index: usize, + node_index: NodeID, + node: &Node, + module: ModuleRef<'_>, + present: u32, + property: &'static str, + id: Interned, +) -> Result<(), InvalidFacts> { + if !node.has(present) { + return Ok(()); + } + + let bytes = module + .string_bytes(id) + .ok_or(InvalidFacts::InvalidStringOffset { + module: module_index, + node: node_index, + property, + offset: id.0, + })?; + std::str::from_utf8(bytes).map_err(|_| InvalidFacts::InvalidUtf8 { + module: module_index, + node: node_index, + property, + offset: id.0, + })?; + Ok(()) +} From 2dff880270e59391a380acd3d6c5f3055e5d7d21 Mon Sep 17 00:00:00 2001 From: Ryan Zmuda Date: Tue, 18 Aug 2026 12:03:08 -0400 Subject: [PATCH 05/16] libreach: consume frozen binary facts --- resolve-facts/CMakeLists.txt | 15 +- resolve-facts/include/reach/distmap.hpp | 6 +- resolve-facts/include/reach/facts.hpp | 9 + resolve-facts/include/reach/graph.hpp | 14 +- resolve-facts/libs/reach/distmap.cpp | 86 ++++++ resolve-facts/libs/reach/facts.cpp | 20 ++ resolve-facts/libs/reach/facts_view.hpp | 169 +++++++++++ resolve-facts/libs/reach/graph.cpp | 357 +++++++++++++++++++++--- 8 files changed, 634 insertions(+), 42 deletions(-) create mode 100644 resolve-facts/libs/reach/facts_view.hpp diff --git a/resolve-facts/CMakeLists.txt b/resolve-facts/CMakeLists.txt index bba8bbc86..b8102da84 100644 --- a/resolve-facts/CMakeLists.txt +++ b/resolve-facts/CMakeLists.txt @@ -152,6 +152,7 @@ file(GLOB_RECURSE SRC file(GLOB_RECURSE LIB "${CMAKE_CURRENT_SOURCE_DIR}/include/reach/*.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/libs/reach/*.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/libs/reach/*.hpp" ) # reach lib @@ -166,10 +167,20 @@ 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) diff --git a/resolve-facts/include/reach/distmap.hpp b/resolve-facts/include/reach/distmap.hpp index 109a58552..7e21a7448 100644 --- a/resolve-facts/include/reach/distmap.hpp +++ b/resolve-facts/include/reach/distmap.hpp @@ -25,4 +25,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..1a64108dc 100644 --- a/resolve-facts/include/reach/facts.hpp +++ b/resolve-facts/include/reach/facts.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -23,6 +24,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 +76,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 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/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/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); From 30147d9cbbd1e6a10fbfdaff976a5da78f5ac751 Mon Sep 17 00:00:00 2001 From: Ryan Zmuda Date: Tue, 18 Aug 2026 12:03:59 -0400 Subject: [PATCH 06/16] reach: expose the binary facts C interface --- resolve-facts/CMakeLists.txt | 2 + resolve-facts/include/reach/ffi.h | 85 +++++++++ resolve-facts/libs/reach/ffi.cpp | 216 +++++++++++++++++++++++ resolve-facts/vendor/json/CMakeLists.txt | 2 +- 4 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 resolve-facts/include/reach/ffi.h create mode 100644 resolve-facts/libs/reach/ffi.cpp diff --git a/resolve-facts/CMakeLists.txt b/resolve-facts/CMakeLists.txt index b8102da84..ac91088b9 100644 --- a/resolve-facts/CMakeLists.txt +++ b/resolve-facts/CMakeLists.txt @@ -150,6 +150,7 @@ file(GLOB_RECURSE SRC ) 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" @@ -159,6 +160,7 @@ file(GLOB_RECURSE 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 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/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/vendor/json/CMakeLists.txt b/resolve-facts/vendor/json/CMakeLists.txt index 28805f904..9d2225a0a 100644 --- a/resolve-facts/vendor/json/CMakeLists.txt +++ b/resolve-facts/vendor/json/CMakeLists.txt @@ -1,6 +1,6 @@ add_library(json INTERFACE) target_include_directories(json INTERFACE - "$/include" + "$" "$" ) From 4d901507be04ff557f94d590a65e7924405f3c1c Mon Sep 17 00:00:00 2001 From: Ryan Zmuda Date: Tue, 18 Aug 2026 12:06:26 -0400 Subject: [PATCH 07/16] klee: consume frozen binary facts --- klee/lib/Core/CMakeLists.txt | 2 - klee/lib/Core/Executor.cpp | 6 +-- klee/tools/klee/main.cpp | 49 +++++++------------ .../resolve_facts_llvm/BinaryLLVMFacts.hpp | 12 +++++ 4 files changed, 32 insertions(+), 37 deletions(-) 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-facts/include/resolve_facts_llvm/BinaryLLVMFacts.hpp b/resolve-facts/include/resolve_facts_llvm/BinaryLLVMFacts.hpp index 40595e4a5..2a30ffc9d 100644 --- a/resolve-facts/include/resolve_facts_llvm/BinaryLLVMFacts.hpp +++ b/resolve-facts/include/resolve_facts_llvm/BinaryLLVMFacts.hpp @@ -20,6 +20,7 @@ #include #include #include +#include namespace resolve { @@ -36,6 +37,8 @@ class BinarySerializedFacts { 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)}; } @@ -99,6 +102,15 @@ class BinaryLLVMFacts { 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; From 922bb983475b2ec9786cac5986efa2883cc5a283 Mon Sep 17 00:00:00 2001 From: Ryan Zmuda Date: Wed, 19 Aug 2026 08:08:24 -0400 Subject: [PATCH 08/16] reach: scaffold new binary --- resolve-cli/src/resolve/reach/Cargo.lock | 247 ++++++++++++++++++++++ resolve-cli/src/resolve/reach/Cargo.toml | 8 + resolve-cli/src/resolve/reach/src/main.rs | 56 +++++ 3 files changed, 311 insertions(+) create mode 100644 resolve-cli/src/resolve/reach/Cargo.lock create mode 100644 resolve-cli/src/resolve/reach/Cargo.toml create mode 100644 resolve-cli/src/resolve/reach/src/main.rs diff --git a/resolve-cli/src/resolve/reach/Cargo.lock b/resolve-cli/src/resolve/reach/Cargo.lock new file mode 100644 index 000000000..4a3dc346d --- /dev/null +++ b/resolve-cli/src/resolve/reach/Cargo.lock @@ -0,0 +1,247 @@ +# 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 = "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", +] + +[[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 = "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 = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[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 = "reach" +version = "0.1.0" +dependencies = [ + "clap", + "serde_json", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[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", +] + +[[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 = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[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 = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[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" diff --git a/resolve-cli/src/resolve/reach/Cargo.toml b/resolve-cli/src/resolve/reach/Cargo.toml new file mode 100644 index 000000000..565435435 --- /dev/null +++ b/resolve-cli/src/resolve/reach/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "reach" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4.6.6", features = ["derive"] } +serde_json = "1.0.151" 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..c2fcd40a5 --- /dev/null +++ b/resolve-cli/src/resolve/reach/src/main.rs @@ -0,0 +1,56 @@ +use std::{fs, path::{PathBuf, Path}}; +use clap::{Parser, ArgAction}; +use serde_json::Value; + +#[derive(Parser,Debug)] +struct Args { + /// Input vulnerabilities.json + #[arg(short, long)] + input: PathBuf, + + /// Files 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, default_value = "reach.json")] // TODO: .reach.json + 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: Option, + + // should we have no-ops for cli compatibility with old reach wrapper? +} + +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 run() -> Result<(), String> { + let args = Args::parse(); + let input = load_vuln_json(&args.input)?; + + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!("error: {error}"); + std::process::exit(1); + } +} From 0961776aa1e4f26278af76ce7ebc470088f07bcb Mon Sep 17 00:00:00 2001 From: Ryan Zmuda Date: Wed, 19 Aug 2026 10:36:10 -0400 Subject: [PATCH 09/16] reach: vcpkg parsing progress --- resolve-cli/src/resolve/reach/Cargo.lock | 310 +++++++++++++++++- resolve-cli/src/resolve/reach/Cargo.toml | 2 + resolve-cli/src/resolve/reach/src/main.rs | 43 ++- resolve-cli/src/resolve/reach/src/vcpkg.rs | 175 ++++++++++ .../src/resolve/reach/src/vulnerability.rs | 84 +++++ 5 files changed, 597 insertions(+), 17 deletions(-) create mode 100644 resolve-cli/src/resolve/reach/src/vcpkg.rs create mode 100644 resolve-cli/src/resolve/reach/src/vulnerability.rs diff --git a/resolve-cli/src/resolve/reach/Cargo.lock b/resolve-cli/src/resolve/reach/Cargo.lock index 4a3dc346d..4442501fb 100644 --- a/resolve-cli/src/resolve/reach/Cargo.lock +++ b/resolve-cli/src/resolve/reach/Cargo.lock @@ -52,6 +52,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[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" @@ -83,7 +95,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -98,6 +110,75 @@ 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 = "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 = "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" @@ -116,18 +197,47 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[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 = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[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 = "proc-macro2" version = "1.0.107" @@ -151,7 +261,34 @@ name = "reach" version = "0.1.0" dependencies = [ "clap", + "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]] @@ -161,6 +298,18 @@ 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]] @@ -180,7 +329,18 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "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]] @@ -196,12 +356,29 @@ dependencies = [ "zmij", ] +[[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" @@ -213,18 +390,147 @@ dependencies = [ "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" diff --git a/resolve-cli/src/resolve/reach/Cargo.toml b/resolve-cli/src/resolve/reach/Cargo.toml index 565435435..aa2fd7837 100644 --- a/resolve-cli/src/resolve/reach/Cargo.toml +++ b/resolve-cli/src/resolve/reach/Cargo.toml @@ -5,4 +5,6 @@ edition = "2024" [dependencies] clap = { version = "4.6.6", features = ["derive"] } +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/src/main.rs b/resolve-cli/src/resolve/reach/src/main.rs index c2fcd40a5..9e89b6f1c 100644 --- a/resolve-cli/src/resolve/reach/src/main.rs +++ b/resolve-cli/src/resolve/reach/src/main.rs @@ -1,8 +1,17 @@ -use std::{fs, path::{PathBuf, Path}}; -use clap::{Parser, ArgAction}; -use serde_json::Value; +use std::{ + fs, + path::{Path, PathBuf}, +}; -#[derive(Parser,Debug)] +use clap::{ArgAction, Parser}; + +use vcpkg::populate_version_results; +use vulnerability::{VulnerabilityAnalysis, VulnerabilityJSON}; + +mod vcpkg; +mod vulnerability; + +#[derive(Parser, Debug)] struct Args { /// Input vulnerabilities.json #[arg(short, long)] @@ -10,7 +19,7 @@ struct Args { /// Files containing facts (ELF, .so, .facts) #[arg(short, long, required = true, num_args=1, action= ArgAction::Append)] - facts: Vec, + facts: Vec, /// The file to write the final report into #[arg(short, long, default_value = "reach.json")] // TODO: .reach.json @@ -21,29 +30,33 @@ struct Args { 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: Option, - // should we have no-ops for cli compatibility with old reach wrapper? } -fn load_vuln_json(path: &Path) -> Result { - let contents = fs::read(path) - .map_err(|error| { - format!("failed to read '{}': {error}", path.display()) - })?; +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()) - }) + .map_err(|error| format!("failed to parse '{}': {error}", path.display())) } fn run() -> Result<(), String> { let args = Args::parse(); let input = load_vuln_json(&args.input)?; + 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." + ); + } Ok(()) } 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..07fa04fe6 --- /dev/null +++ b/resolve-cli/src/resolve/reach/src/vcpkg.rs @@ -0,0 +1,175 @@ +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, +} + +fn normalize_semver(version: &str) -> String { + let version = version.trim(); + let suffix_start = version + .find(|character| character == '-' || character == '+') + .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.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; + }; + + sink.package_version = Some(actual_version.clone()); + + println!( + "[REACH] Populated package version for '{}' from '{}': {}", + sink.vuln.package_name, + manifest_path.display(), + actual_version + ); + + if is_vulnerable(&sink.vuln.package_version, &actual_version)? { + println!( + "[REACH] Package version '{}' is vulnerable according to '{}'.", + actual_version, sink.vuln.package_version + ); + } else { + sink.reachability = ReachabilityStatus::NotVulnerable; + println!( + "[REACH] Package version '{}' is not vulnerable according to '{}'.", + actual_version, sink.vuln.package_version + ); + } + } + + 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..8f53decb0 --- /dev/null +++ b/resolve-cli/src/resolve/reach/src/vulnerability.rs @@ -0,0 +1,84 @@ +use serde::{Deserialize, Serialize}; + +/// vulnerabilities.json +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct VulnerabilityJSON { + pub vulnerabilities: Vec, +} + +/// A vulnerability inside vulnerabilities.json +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Vulnerability { + #[serde(alias = "cve_id")] + pub cve_id: String, + #[serde(alias = "cve_description")] + pub cve_description: String, + #[serde(alias = "package_name")] + pub package_name: String, + #[serde(alias = "package_version")] + pub package_version: String, + #[serde(alias = "cwe_id")] + pub cwe_id: String, + #[serde(alias = "cwe_name")] + pub cwe_name: String, + #[serde(alias = "affected_function")] + pub affected_function: String, + #[serde(alias = "affected_file")] + pub affected_file: String, + #[serde( + alias = "remediation_strategy", + default, + skip_serializing_if = "Option::is_none" + )] + pub remediation_strategy: Option, + #[serde( + alias = "undesirable_function", + default, + skip_serializing_if = "Option::is_none" + )] + pub undesirable_function: Option, +} + +#[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 package_version: Option, + pub reachability: ReachabilityStatus, + // pub func_id: Option, + // pub paths: Vec, +} + +impl From for VulnerabilityAnalysis { + fn from(vulnerability: Vulnerability) -> Self { + Self { + vuln: vulnerability, + package_version: None, + reachability: ReachabilityStatus::Unknown, + // function_id: None, + // paths: Vec::new(), + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum RemediationStrategy { + Continue, + Exit, + None, + Recover, + Sat, + Widen, + Wrap, +} From 0547daca5fe73651fcc3eb8414fe84947e631464 Mon Sep 17 00:00:00 2001 From: Ryan Zmuda Date: Wed, 19 Aug 2026 13:37:13 -0400 Subject: [PATCH 10/16] reach: import facts with facts_rs and build libreach graph --- Makefile | 2 +- resolve-cli/CMakeLists.txt | 47 ++++ resolve-cli/src/resolve/reach/Cargo.lock | 130 +++++++++ resolve-cli/src/resolve/reach/Cargo.toml | 2 + resolve-cli/src/resolve/reach/build.rs | 34 +++ resolve-cli/src/resolve/reach/src/libreach.rs | 248 ++++++++++++++++++ resolve-cli/src/resolve/reach/src/main.rs | 25 ++ 7 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 resolve-cli/src/resolve/reach/build.rs create mode 100644 resolve-cli/src/resolve/reach/src/libreach.rs diff --git a/Makefile b/Makefile index e1e1208c4..7a47f558a 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-reach-rs test-with-klee: $(MAKE) test RESOLVE_BUILD_KLEE=ON diff --git a/resolve-cli/CMakeLists.txt b/resolve-cli/CMakeLists.txt index c4fe57b91..b0bb545eb 100644 --- a/resolve-cli/CMakeLists.txt +++ b/resolve-cli/CMakeLists.txt @@ -4,6 +4,53 @@ 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 Rust replacement for the Python reachability wrapper +find_program(CARGO_EXECUTABLE cargo REQUIRED) + +if(CMAKE_BUILD_TYPE STREQUAL "Debug" OR CMAKE_BUILD_TYPE STREQUAL "") + set(REACH_RS_CARGO_PROFILE debug) + set(REACH_RS_CARGO_FLAGS) +else() + set(REACH_RS_CARGO_PROFILE release) + set(REACH_RS_CARGO_FLAGS --release) +endif() + +set(REACH_RS_CRATE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/src/resolve/reach") +set(REACH_RS_TARGET_DIR "${CMAKE_CURRENT_BINARY_DIR}/reach-rs-target") +set(REACH_RS_BINARY "${REACH_RS_TARGET_DIR}/${REACH_RS_CARGO_PROFILE}/reach") +set(REACH_RS_CARGO_ENV + "CARGO_TARGET_DIR=${REACH_RS_TARGET_DIR}" + "RESOLVE_LIBREACH_DIR=$" +) + +add_custom_target(reach-rs ALL + COMMAND ${CMAKE_COMMAND} -E env + ${REACH_RS_CARGO_ENV} + ${CARGO_EXECUTABLE} build --locked ${REACH_RS_CARGO_FLAGS} + WORKING_DIRECTORY "${REACH_RS_CRATE_DIR}" + BYPRODUCTS "${REACH_RS_BINARY}" + DEPENDS libreach + COMMENT "Building the Rust reach binary" + USES_TERMINAL + VERBATIM +) + +add_custom_target(test-reach-rs + COMMAND ${CMAKE_COMMAND} -E env + ${REACH_RS_CARGO_ENV} + ${CARGO_EXECUTABLE} test --locked + WORKING_DIRECTORY "${REACH_RS_CRATE_DIR}" + DEPENDS libreach + COMMENT "Running the Rust reach tests" + USES_TERMINAL + VERBATIM +) + +install(PROGRAMS "${REACH_RS_BINARY}" + DESTINATION "${CMAKE_INSTALL_BINDIR}" + RENAME reach-rs +) + # Make the install prefix a Python environment for the resolve CLI tools. install(CODE " set(_resolve_python_version \"${RESOLVE_PYTHON_VERSION}\") diff --git a/resolve-cli/src/resolve/reach/Cargo.lock b/resolve-cli/src/resolve/reach/Cargo.lock index 4442501fb..8a527d55b 100644 --- a/resolve-cli/src/resolve/reach/Cargo.lock +++ b/resolve-cli/src/resolve/reach/Cargo.lock @@ -58,6 +58,38 @@ 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" @@ -142,6 +174,21 @@ dependencies = [ "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" @@ -166,6 +213,17 @@ dependencies = [ "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" @@ -197,6 +255,16 @@ 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" @@ -208,12 +276,27 @@ dependencies = [ "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" @@ -238,6 +321,12 @@ 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" @@ -256,11 +345,18 @@ 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 = "reach" version = "0.1.0" dependencies = [ "clap", + "facts-rs", "serde", "serde_json", "vers-rs", @@ -356,6 +452,12 @@ dependencies = [ "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" @@ -551,3 +653,31 @@ 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 index aa2fd7837..f904a0879 100644 --- a/resolve-cli/src/resolve/reach/Cargo.toml +++ b/resolve-cli/src/resolve/reach/Cargo.toml @@ -2,9 +2,11 @@ name = "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..00134f79f --- /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 reach-rs 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/libreach.rs b/resolve-cli/src/resolve/reach/src/libreach.rs new file mode 100644 index 000000000..2f26b23f7 --- /dev/null +++ b/resolve-cli/src/resolve/reach/src/libreach.rs @@ -0,0 +1,248 @@ +use std::{ffi::c_void, ptr::NonNull, slice}; + +use facts_rs::FactsBuf; + +#[repr(C)] +struct ReachGraph { + _private: [u8; 0], +} + +#[repr(C)] +struct ReachQueryResult { + _private: [u8; 0], +} + +#[repr(C)] +struct ReachError { + _private: [u8; 0], +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[repr(C)] +pub struct ReachNodeID { + pub module: u32, + pub node: u32, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReachEdgeType { + DirectCall, + IndirectCall, + Contains, + Successor, + External, + ExternalIndirectCall, +} + +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, +} + +#[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 (), + 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(facts: &FactsBuf) -> Result { + let mut error = std::ptr::null_mut(); + let graph = unsafe { + reach_graph_build( + std::ptr::from_ref(facts).cast(), + std::ptr::null(), + &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 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::{ReachEdgeType, Graph, 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(&builder.freeze()).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 index 9e89b6f1c..659ae2fda 100644 --- a/resolve-cli/src/resolve/reach/src/main.rs +++ b/resolve-cli/src/resolve/reach/src/main.rs @@ -4,10 +4,13 @@ use std::{ }; use clap::{ArgAction, Parser}; +use facts_rs::FactsBuf; +use libreach::Graph; use vcpkg::populate_version_results; use vulnerability::{VulnerabilityAnalysis, VulnerabilityJSON}; +mod libreach; mod vcpkg; mod vulnerability; @@ -44,6 +47,10 @@ fn load_vuln_json(path: &Path) -> Result { .map_err(|error| format!("failed to parse '{}': {error}", path.display())) } +fn load_facts(paths: &[PathBuf]) -> Result { + FactsBuf::read_files(paths).map_err(|error| format!("failed to load facts: {error}")) +} + fn run() -> Result<(), String> { let args = Args::parse(); let input = load_vuln_json(&args.input)?; @@ -58,6 +65,24 @@ fn run() -> Result<(), String> { ); } + let facts = 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.", + args.facts.len() + ); + + let graph = Graph::build(&facts)?; + println!( + "[REACH] Built a libreach graph with {} edges.", + graph.edge_count() + ); + Ok(()) } From 28dabef75fe688630e875cdd6e5a59236c943e08 Mon Sep 17 00:00:00 2001 From: Ryan Zmuda Date: Wed, 19 Aug 2026 13:51:16 -0400 Subject: [PATCH 11/16] reach: do reachability with libreach --- resolve-cli/src/resolve/reach/src/analysis.rs | 59 +++++++ .../src/resolve/reach/src/functions.rs | 150 ++++++++++++++++++ resolve-cli/src/resolve/reach/src/libreach.rs | 6 +- resolve-cli/src/resolve/reach/src/main.rs | 12 +- .../src/resolve/reach/src/vulnerability.rs | 10 +- 5 files changed, 223 insertions(+), 14 deletions(-) create mode 100644 resolve-cli/src/resolve/reach/src/analysis.rs create mode 100644 resolve-cli/src/resolve/reach/src/functions.rs 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..7a5270d14 --- /dev/null +++ b/resolve-cli/src/resolve/reach/src/analysis.rs @@ -0,0 +1,59 @@ +use facts_rs::FactsBuf; + +use crate::{ + functions::FunctionIndex, + libreach::Graph, + vulnerability::{ReachabilityStatus, VulnerabilityAnalysis}, +}; + +pub fn populate_reachability_results( + analyses: &mut [VulnerabilityAnalysis], + facts: &FactsBuf, + entry: &str, +) -> Result<(), String> { + let functions = FunctionIndex::build(facts)?; + 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(facts)?; + 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..00ae3b34a --- /dev/null +++ b/resolve-cli/src/resolve/reach/src/functions.rs @@ -0,0 +1,150 @@ +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) + } +} + +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 index 2f26b23f7..6ac3d07d4 100644 --- a/resolve-cli/src/resolve/reach/src/libreach.rs +++ b/resolve-cli/src/resolve/reach/src/libreach.rs @@ -1,6 +1,6 @@ use std::{ffi::c_void, ptr::NonNull, slice}; -use facts_rs::FactsBuf; +use facts_rs::{FactsBuf, NodeID}; #[repr(C)] struct ReachGraph { @@ -21,7 +21,7 @@ struct ReachError { #[repr(C)] pub struct ReachNodeID { pub module: u32, - pub node: u32, + pub node: NodeID, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -215,7 +215,7 @@ unsafe fn take_error(error: *mut ReachError, fallback: &str) -> String { mod tests { use facts_rs::{EdgeKind, FactsBuilder, NodeType}; - use super::{ReachEdgeType, Graph, ReachNodeID}; + use super::{Graph, ReachEdgeType, ReachNodeID}; #[test] fn builds_and_queries_a_graph() { diff --git a/resolve-cli/src/resolve/reach/src/main.rs b/resolve-cli/src/resolve/reach/src/main.rs index 659ae2fda..942dd3ffd 100644 --- a/resolve-cli/src/resolve/reach/src/main.rs +++ b/resolve-cli/src/resolve/reach/src/main.rs @@ -6,10 +6,12 @@ use std::{ use clap::{ArgAction, Parser}; use facts_rs::FactsBuf; -use libreach::Graph; +use analysis::populate_reachability_results; use vcpkg::populate_version_results; use vulnerability::{VulnerabilityAnalysis, VulnerabilityJSON}; +mod analysis; +mod functions; mod libreach; mod vcpkg; mod vulnerability; @@ -35,7 +37,7 @@ struct Args { // TODO: C++ WORKER ARGS HERE FOR OTHER SETTINGS /// Entry function to traverse to vulnerable sink from #[arg(short, long, default_value = "main")] - entry: Option, + entry: String, // should we have no-ops for cli compatibility with old reach wrapper? } @@ -77,11 +79,7 @@ fn run() -> Result<(), String> { args.facts.len() ); - let graph = Graph::build(&facts)?; - println!( - "[REACH] Built a libreach graph with {} edges.", - graph.edge_count() - ); + populate_reachability_results(&mut analyses, &facts, &args.entry)?; Ok(()) } diff --git a/resolve-cli/src/resolve/reach/src/vulnerability.rs b/resolve-cli/src/resolve/reach/src/vulnerability.rs index 8f53decb0..5bf726804 100644 --- a/resolve-cli/src/resolve/reach/src/vulnerability.rs +++ b/resolve-cli/src/resolve/reach/src/vulnerability.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::libreach::{ReachNodeID, ReachPath}; + /// vulnerabilities.json #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct VulnerabilityJSON { @@ -55,8 +57,8 @@ pub struct VulnerabilityAnalysis { pub vuln: Vulnerability, pub package_version: Option, pub reachability: ReachabilityStatus, - // pub func_id: Option, - // pub paths: Vec, + pub function_id: Option, + pub paths: Vec, } impl From for VulnerabilityAnalysis { @@ -65,8 +67,8 @@ impl From for VulnerabilityAnalysis { vuln: vulnerability, package_version: None, reachability: ReachabilityStatus::Unknown, - // function_id: None, - // paths: Vec::new(), + function_id: None, + paths: Vec::new(), } } } From e762c44298f67131197fb46b1117dc1cd7b41d13 Mon Sep 17 00:00:00 2001 From: Ryan Zmuda Date: Wed, 19 Aug 2026 14:00:56 -0400 Subject: [PATCH 12/16] reach: write report --- resolve-cli/src/resolve/reach/src/analysis.rs | 2 +- .../src/resolve/reach/src/functions.rs | 7 + resolve-cli/src/resolve/reach/src/libreach.rs | 13 + resolve-cli/src/resolve/reach/src/main.rs | 9 +- .../src/resolve/reach/src/serializer.rs | 227 ++++++++++++++++++ 5 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 resolve-cli/src/resolve/reach/src/serializer.rs diff --git a/resolve-cli/src/resolve/reach/src/analysis.rs b/resolve-cli/src/resolve/reach/src/analysis.rs index 7a5270d14..68cadcb75 100644 --- a/resolve-cli/src/resolve/reach/src/analysis.rs +++ b/resolve-cli/src/resolve/reach/src/analysis.rs @@ -9,9 +9,9 @@ use crate::{ pub fn populate_reachability_results( analyses: &mut [VulnerabilityAnalysis], facts: &FactsBuf, + functions: &FunctionIndex, entry: &str, ) -> Result<(), String> { - let functions = FunctionIndex::build(facts)?; let entry_id = functions .find(entry, "") .ok_or_else(|| format!("entry function '{entry}' was not found in the facts"))?; diff --git a/resolve-cli/src/resolve/reach/src/functions.rs b/resolve-cli/src/resolve/reach/src/functions.rs index 00ae3b34a..44159928a 100644 --- a/resolve-cli/src/resolve/reach/src/functions.rs +++ b/resolve-cli/src/resolve/reach/src/functions.rs @@ -93,6 +93,13 @@ impl FunctionIndex { 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 { diff --git a/resolve-cli/src/resolve/reach/src/libreach.rs b/resolve-cli/src/resolve/reach/src/libreach.rs index 6ac3d07d4..3f2b4651b 100644 --- a/resolve-cli/src/resolve/reach/src/libreach.rs +++ b/resolve-cli/src/resolve/reach/src/libreach.rs @@ -34,6 +34,19 @@ pub enum ReachEdgeType { 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; diff --git a/resolve-cli/src/resolve/reach/src/main.rs b/resolve-cli/src/resolve/reach/src/main.rs index 942dd3ffd..2d60d9261 100644 --- a/resolve-cli/src/resolve/reach/src/main.rs +++ b/resolve-cli/src/resolve/reach/src/main.rs @@ -7,12 +7,15 @@ use clap::{ArgAction, Parser}; use facts_rs::FactsBuf; use analysis::populate_reachability_results; +use functions::FunctionIndex; +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; @@ -28,7 +31,7 @@ struct Args { /// The file to write the final report into #[arg(short, long, default_value = "reach.json")] // TODO: .reach.json - output: Option, + output: PathBuf, /// Source tree containing vcpkg-overlays #[arg(short, long)] @@ -79,7 +82,9 @@ fn run() -> Result<(), String> { args.facts.len() ); - populate_reachability_results(&mut analyses, &facts, &args.entry)?; + let functions = FunctionIndex::build(&facts)?; + populate_reachability_results(&mut analyses, &facts, &functions, &args.entry)?; + write_report(&args.output, &analyses, &facts, &functions)?; Ok(()) } 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, + } + } +} From 52f3b61b6fa13599c3119f56868fdf4c124788ce Mon Sep 17 00:00:00 2001 From: Ryan Zmuda Date: Wed, 19 Aug 2026 14:30:00 -0400 Subject: [PATCH 13/16] reach: cleanup schema and add compat options --- resolve-cli/src/resolve/reach/src/analysis.rs | 5 +- resolve-cli/src/resolve/reach/src/libreach.rs | 75 +++++++++++++++++-- resolve-cli/src/resolve/reach/src/main.rs | 38 +++++++++- resolve-cli/src/resolve/reach/src/vcpkg.rs | 46 ++++++++---- .../src/resolve/reach/src/vulnerability.rs | 38 +--------- 5 files changed, 143 insertions(+), 59 deletions(-) diff --git a/resolve-cli/src/resolve/reach/src/analysis.rs b/resolve-cli/src/resolve/reach/src/analysis.rs index 68cadcb75..6c1ebae2f 100644 --- a/resolve-cli/src/resolve/reach/src/analysis.rs +++ b/resolve-cli/src/resolve/reach/src/analysis.rs @@ -2,7 +2,7 @@ use facts_rs::FactsBuf; use crate::{ functions::FunctionIndex, - libreach::Graph, + libreach::{Graph, GraphBuildOptions}, vulnerability::{ReachabilityStatus, VulnerabilityAnalysis}, }; @@ -11,6 +11,7 @@ pub fn populate_reachability_results( facts: &FactsBuf, functions: &FunctionIndex, entry: &str, + graph_options: &GraphBuildOptions<'_>, ) -> Result<(), String> { let entry_id = functions .find(entry, "") @@ -34,7 +35,7 @@ pub fn populate_reachability_results( return Ok(()); } - let graph = Graph::build(facts)?; + let graph = Graph::build_with_options(facts, graph_options)?; println!( "[REACH] Built a libreach graph with {} edges.", graph.edge_count() diff --git a/resolve-cli/src/resolve/reach/src/libreach.rs b/resolve-cli/src/resolve/reach/src/libreach.rs index 3f2b4651b..b3bfe3128 100644 --- a/resolve-cli/src/resolve/reach/src/libreach.rs +++ b/resolve-cli/src/resolve/reach/src/libreach.rs @@ -1,6 +1,7 @@ use std::{ffi::c_void, ptr::NonNull, slice}; use facts_rs::{FactsBuf, NodeID}; +use serde::Deserialize; #[repr(C)] struct ReachGraph { @@ -17,6 +18,26 @@ 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 { @@ -69,6 +90,18 @@ pub struct ReachPath { 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, @@ -80,7 +113,7 @@ struct ReachPathView { unsafe extern "C" { fn reach_graph_build( facts: *const c_void, - options: *const (), + options: *const ReachBuildOptions, error: *mut *mut ReachError, ) -> *mut ReachGraph; fn reach_graph_free(graph: *mut ReachGraph); @@ -111,12 +144,34 @@ pub struct Graph { } impl Graph { - pub fn build(facts: &FactsBuf) -> Result { + 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::null(), + std::ptr::from_ref(&ffi_options), &mut error, ) }; @@ -155,6 +210,15 @@ impl Graph { } } +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()) }; @@ -228,7 +292,7 @@ unsafe fn take_error(error: *mut ReachError, fallback: &str) -> String { mod tests { use facts_rs::{EdgeKind, FactsBuilder, NodeType}; - use super::{Graph, ReachEdgeType, ReachNodeID}; + use super::{Graph, GraphBuildOptions, ReachEdgeType, ReachNodeID}; #[test] fn builds_and_queries_a_graph() { @@ -239,7 +303,8 @@ mod tests { let block = builder.add_node(module, NodeType::BasicBlock).unwrap(); assert!(builder.add_edge(module, function, block, EdgeKind::EntryPoint)); - let graph = Graph::build(&builder.freeze()).unwrap(); + let graph = + Graph::build_with_options(&builder.freeze(), &GraphBuildOptions::default()).unwrap(); let paths = graph .query( ReachNodeID { diff --git a/resolve-cli/src/resolve/reach/src/main.rs b/resolve-cli/src/resolve/reach/src/main.rs index 2d60d9261..9b225511d 100644 --- a/resolve-cli/src/resolve/reach/src/main.rs +++ b/resolve-cli/src/resolve/reach/src/main.rs @@ -5,9 +5,11 @@ use std::{ 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}; @@ -41,7 +43,19 @@ struct Args { /// Entry function to traverse to vulnerable sink from #[arg(short, long, default_value = "main")] entry: String, - // should we have no-ops for cli compatibility with old reach wrapper? + + /// 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 { @@ -56,6 +70,15 @@ fn load_facts(paths: &[PathBuf]) -> Result { FactsBuf::read_files(paths).map_err(|error| format!("failed to load facts: {error}")) } +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)?; @@ -83,7 +106,18 @@ fn run() -> Result<(), String> { ); let functions = FunctionIndex::build(&facts)?; - populate_reachability_results(&mut analyses, &facts, &functions, &args.entry)?; + 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(&args.output, &analyses, &facts, &functions)?; Ok(()) diff --git a/resolve-cli/src/resolve/reach/src/vcpkg.rs b/resolve-cli/src/resolve/reach/src/vcpkg.rs index 07fa04fe6..16bef7c56 100644 --- a/resolve-cli/src/resolve/reach/src/vcpkg.rs +++ b/resolve-cli/src/resolve/reach/src/vcpkg.rs @@ -14,13 +14,26 @@ use crate::vulnerability::{ReachabilityStatus, VulnerabilityAnalysis}; 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(|character| character == '-' || character == '+') - .unwrap_or(version.len()); + let suffix_start = version.find(['-', '+']).unwrap_or(version.len()); let (core, suffix) = version.split_at(suffix_start); let components: Vec<&str> = core.split('.').collect(); @@ -103,7 +116,7 @@ fn get_version(src_dir: &Path, package_name: &str) -> Result<(Option, Pa return Ok((None, manifest_path)); } - Ok((manifest.version, manifest_path)) + Ok((manifest.into_version(), manifest_path)) } pub fn populate_version_results( @@ -120,8 +133,6 @@ pub fn populate_version_results( continue; }; - sink.package_version = Some(actual_version.clone()); - println!( "[REACH] Populated package version for '{}' from '{}': {}", sink.vuln.package_name, @@ -129,17 +140,22 @@ pub fn populate_version_results( actual_version ); - if is_vulnerable(&sink.vuln.package_version, &actual_version)? { - println!( + 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 - ); - } else { - sink.reachability = ReachabilityStatus::NotVulnerable; - println!( - "[REACH] Package version '{}' is not 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 + ), } } diff --git a/resolve-cli/src/resolve/reach/src/vulnerability.rs b/resolve-cli/src/resolve/reach/src/vulnerability.rs index 5bf726804..750f84d98 100644 --- a/resolve-cli/src/resolve/reach/src/vulnerability.rs +++ b/resolve-cli/src/resolve/reach/src/vulnerability.rs @@ -1,45 +1,27 @@ -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use crate::libreach::{ReachNodeID, ReachPath}; /// vulnerabilities.json -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Debug, Deserialize)] pub struct VulnerabilityJSON { pub vulnerabilities: Vec, } /// A vulnerability inside vulnerabilities.json -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Debug, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct Vulnerability { #[serde(alias = "cve_id")] pub cve_id: String, - #[serde(alias = "cve_description")] - pub cve_description: String, #[serde(alias = "package_name")] pub package_name: String, #[serde(alias = "package_version")] pub package_version: String, - #[serde(alias = "cwe_id")] - pub cwe_id: String, - #[serde(alias = "cwe_name")] - pub cwe_name: String, #[serde(alias = "affected_function")] pub affected_function: String, #[serde(alias = "affected_file")] pub affected_file: String, - #[serde( - alias = "remediation_strategy", - default, - skip_serializing_if = "Option::is_none" - )] - pub remediation_strategy: Option, - #[serde( - alias = "undesirable_function", - default, - skip_serializing_if = "Option::is_none" - )] - pub undesirable_function: Option, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -55,7 +37,6 @@ pub enum ReachabilityStatus { #[derive(Debug)] pub struct VulnerabilityAnalysis { pub vuln: Vulnerability, - pub package_version: Option, pub reachability: ReachabilityStatus, pub function_id: Option, pub paths: Vec, @@ -65,22 +46,9 @@ impl From for VulnerabilityAnalysis { fn from(vulnerability: Vulnerability) -> Self { Self { vuln: vulnerability, - package_version: None, reachability: ReachabilityStatus::Unknown, function_id: None, paths: Vec::new(), } } } - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum RemediationStrategy { - Continue, - Exit, - None, - Recover, - Sat, - Widen, - Wrap, -} From 336f9d67b02a50dfc18d7cde17ea6869266e8d5e Mon Sep 17 00:00:00 2001 From: Ryan Zmuda Date: Wed, 19 Aug 2026 14:57:28 -0400 Subject: [PATCH 14/16] reach: cut over to the Rust CLI --- Makefile | 2 +- docs/components/facts.md | 7 +- docs/components/reach.md | 158 ++-- docs/examples/reachability.md | 17 +- .../analyze-images/compose-analyze-image.yml | 3 +- examples/misc/openssl.sh | 7 +- resolve-cc/src/ResolveFactsPluginPass.cpp | 8 +- resolve-cli/CMakeLists.txt | 64 +- resolve-cli/pyproject.toml | 2 - resolve-cli/src/resolve/cli.py | 4 +- resolve-cli/src/resolve/reach.py | 735 ------------------ resolve-cli/src/resolve/reach/Cargo.lock | 2 +- resolve-cli/src/resolve/reach/Cargo.toml | 2 +- resolve-cli/src/resolve/reach/build.rs | 2 +- resolve-cli/src/resolve/reach/src/main.rs | 65 +- resolve-cli/uv.lock | 63 +- resolve-facts/CMakeLists.txt | 16 +- resolve-facts/README.md | 4 +- resolve-facts/include/reach/distmap.hpp | 1 + resolve-facts/include/reach/facts.hpp | 26 +- .../include/resolve_facts_llvm/LLVMFacts.hpp | 212 ----- .../resolve_facts_llvm/resolve_facts_llvm.hpp | 48 -- .../resolve_facts_llvm/resolve_facts_llvm.cpp | 183 ----- resolve-facts/src/reach/config.hpp | 92 --- resolve-facts/src/reach/main.cpp | 377 --------- scripts/package-release.sh | 2 +- 26 files changed, 207 insertions(+), 1895 deletions(-) delete mode 100644 resolve-cli/src/resolve/reach.py delete mode 100644 resolve-facts/include/resolve_facts_llvm/LLVMFacts.hpp delete mode 100644 resolve-facts/include/resolve_facts_llvm/resolve_facts_llvm.hpp delete mode 100644 resolve-facts/libs/resolve_facts_llvm/resolve_facts_llvm.cpp delete mode 100644 resolve-facts/src/reach/config.hpp delete mode 100644 resolve-facts/src/reach/main.cpp diff --git a/Makefile b/Makefile index 7a47f558a..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 test-reach-rs + 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..57796ef6b 100644 --- a/docs/components/facts.md +++ b/docs/components/facts.md @@ -1,8 +1,7 @@ # 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. +Fact generation extracts structured information from [LLVM IR](https://llvm.org/docs/LangRef.html). Each fact describes a program node, property, or relationship. -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. +The compiler pass writes a compact binary format. It compresses the data with zstd and embeds it in the ELF `.facts` section. -!!! 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 reads facts from an ELF file or an extracted `.facts` file. The [reachability example](../examples/reachability.md) shows the complete workflow. 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/examples/reachability.md b/docs/examples/reachability.md index c9781da14..82b902671 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 [`vulnerabilities.json`](../concepts/vulnerabilities-json.md). 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" } @@ -74,13 +71,12 @@ resolve reach -i vulnerabilities.json -f main.facts -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: +`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 5 edges. +[REACH] Wrote 'out.json'. ``` ## Interpreting the Report @@ -126,7 +122,7 @@ 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: @@ -139,4 +135,3 @@ resolve reach -i vulnerabilities.json -f main.facts -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/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 b0bb545eb..4a7653a9a 100644 --- a/resolve-cli/CMakeLists.txt +++ b/resolve-cli/CMakeLists.txt @@ -4,53 +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 Rust replacement for the Python reachability wrapper +# Build the reachability command. find_program(CARGO_EXECUTABLE cargo REQUIRED) if(CMAKE_BUILD_TYPE STREQUAL "Debug" OR CMAKE_BUILD_TYPE STREQUAL "") - set(REACH_RS_CARGO_PROFILE debug) - set(REACH_RS_CARGO_FLAGS) + set(REACH_CARGO_PROFILE debug) + set(REACH_CARGO_FLAGS) else() - set(REACH_RS_CARGO_PROFILE release) - set(REACH_RS_CARGO_FLAGS --release) + set(REACH_CARGO_PROFILE release) + set(REACH_CARGO_FLAGS --release) endif() -set(REACH_RS_CRATE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/src/resolve/reach") -set(REACH_RS_TARGET_DIR "${CMAKE_CURRENT_BINARY_DIR}/reach-rs-target") -set(REACH_RS_BINARY "${REACH_RS_TARGET_DIR}/${REACH_RS_CARGO_PROFILE}/reach") -set(REACH_RS_CARGO_ENV - "CARGO_TARGET_DIR=${REACH_RS_TARGET_DIR}" +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(reach-rs ALL +add_custom_target(resolve-reach ALL COMMAND ${CMAKE_COMMAND} -E env - ${REACH_RS_CARGO_ENV} - ${CARGO_EXECUTABLE} build --locked ${REACH_RS_CARGO_FLAGS} - WORKING_DIRECTORY "${REACH_RS_CRATE_DIR}" - BYPRODUCTS "${REACH_RS_BINARY}" + ${REACH_CARGO_ENV} + ${CARGO_EXECUTABLE} build --locked ${REACH_CARGO_FLAGS} + WORKING_DIRECTORY "${REACH_CRATE_DIR}" + BYPRODUCTS "${REACH_BINARY}" DEPENDS libreach - COMMENT "Building the Rust reach binary" + COMMENT "Building resolve-reach" USES_TERMINAL VERBATIM ) -add_custom_target(test-reach-rs +add_custom_target(test-resolve-reach COMMAND ${CMAKE_COMMAND} -E env - ${REACH_RS_CARGO_ENV} + ${REACH_CARGO_ENV} ${CARGO_EXECUTABLE} test --locked - WORKING_DIRECTORY "${REACH_RS_CRATE_DIR}" + WORKING_DIRECTORY "${REACH_CRATE_DIR}" DEPENDS libreach - COMMENT "Running the Rust reach tests" + COMMENT "Running the resolve-reach tests" USES_TERMINAL VERBATIM ) -install(PROGRAMS "${REACH_RS_BINARY}" - DESTINATION "${CMAKE_INSTALL_BINDIR}" - RENAME reach-rs -) - # Make the install prefix a Python environment for the resolve CLI tools. install(CODE " set(_resolve_python_version \"${RESOLVE_PYTHON_VERSION}\") @@ -140,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 index 8a527d55b..742f4aae8 100644 --- a/resolve-cli/src/resolve/reach/Cargo.lock +++ b/resolve-cli/src/resolve/reach/Cargo.lock @@ -352,7 +352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "reach" +name = "resolve-reach" version = "0.1.0" dependencies = [ "clap", diff --git a/resolve-cli/src/resolve/reach/Cargo.toml b/resolve-cli/src/resolve/reach/Cargo.toml index f904a0879..3158dcabb 100644 --- a/resolve-cli/src/resolve/reach/Cargo.toml +++ b/resolve-cli/src/resolve/reach/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "reach" +name = "resolve-reach" version = "0.1.0" edition = "2024" build = "build.rs" diff --git a/resolve-cli/src/resolve/reach/build.rs b/resolve-cli/src/resolve/reach/build.rs index 00134f79f..7bab10d7b 100644 --- a/resolve-cli/src/resolve/reach/build.rs +++ b/resolve-cli/src/resolve/reach/build.rs @@ -5,7 +5,7 @@ fn main() { let library_dir = PathBuf::from( env::var_os("RESOLVE_LIBREACH_DIR").expect( - "RESOLVE_LIBREACH_DIR is not set; build through the CMake reach-rs target or set it to the native library directory", + "RESOLVE_LIBREACH_DIR is not set; build through the CMake resolve-reach target or set it to the native library directory", ), ); diff --git a/resolve-cli/src/resolve/reach/src/main.rs b/resolve-cli/src/resolve/reach/src/main.rs index 9b225511d..13905e20b 100644 --- a/resolve-cli/src/resolve/reach/src/main.rs +++ b/resolve-cli/src/resolve/reach/src/main.rs @@ -22,18 +22,22 @@ 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 containing facts (ELF, .so, .facts) + /// 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, default_value = "reach.json")] // TODO: .reach.json - output: PathBuf, + #[arg(short, long)] + output: Option, /// Source tree containing vcpkg-overlays #[arg(short, long)] @@ -66,8 +70,50 @@ fn load_vuln_json(path: &Path) -> Result { .map_err(|error| format!("failed to parse '{}': {error}", path.display())) } -fn load_facts(paths: &[PathBuf]) -> Result { - FactsBuf::read_files(paths).map_err(|error| format!("failed to load facts: {error}")) +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> { @@ -82,6 +128,9 @@ fn load_dlsym_log(path: &Path) -> Result, String> { 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(); @@ -93,7 +142,7 @@ fn run() -> Result<(), String> { ); } - let facts = load_facts(&args.facts)?; + let (facts, facts_file_count) = load_facts(&args.facts)?; let module_count = facts .view() .modules() @@ -102,7 +151,7 @@ fn run() -> Result<(), String> { println!( "[REACH] Loaded {module_count} facts modules from {} input files.", - args.facts.len() + facts_file_count ); let functions = FunctionIndex::build(&facts)?; @@ -118,7 +167,7 @@ fn run() -> Result<(), String> { &args.entry, &graph_options, )?; - write_report(&args.output, &analyses, &facts, &functions)?; + write_report(&output, &analyses, &facts, &functions)?; Ok(()) } 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 ac91088b9..80950c789 100644 --- a/resolve-facts/CMakeLists.txt +++ b/resolve-facts/CMakeLists.txt @@ -110,12 +110,10 @@ file(GLOB_RECURSE SRC # Build Targets add_library(resolve_facts_llvm STATIC libs/resolve_facts_llvm/binary_facts_llvm.cpp - libs/resolve_facts_llvm/resolve_facts_llvm.cpp ) target_include_directories(resolve_facts_llvm SYSTEM PUBLIC ${LLVM_INCLUDE_DIRS}) find_package(Threads REQUIRED) target_link_libraries(resolve_facts_llvm PUBLIC - resolve_facts facts_rs Threads::Threads ${CMAKE_DL_LIBS} @@ -143,12 +141,6 @@ 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" @@ -186,17 +178,11 @@ target_link_libraries(libreach PUBLIC 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/README.md b/resolve-facts/README.md index c3e72624f..04b14d287 100644 --- a/resolve-facts/README.md +++ b/resolve-facts/README.md @@ -5,12 +5,12 @@ # 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 diff --git a/resolve-facts/include/reach/distmap.hpp b/resolve-facts/include/reach/distmap.hpp index 7e21a7448..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; diff --git a/resolve-facts/include/reach/facts.hpp b/resolve-facts/include/reach/facts.hpp index 1a64108dc..2f406d751 100644 --- a/resolve-facts/include/reach/facts.hpp +++ b/resolve-facts/include/reach/facts.hpp @@ -5,15 +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; @@ -89,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/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/resolve_facts_llvm.hpp deleted file mode 100644 index 9fa4da0c6..000000000 --- a/resolve-facts/include/resolve_facts_llvm/resolve_facts_llvm.hpp +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025 Riverside Research. - * LGPL-3; See LICENSE.txt in the repo root for details. - */ - -#include "resolve_facts/resolve_facts.hpp" -#include "resolve_facts_llvm/LLVMFacts.hpp" - -#include "llvm/ADT/SmallString.h" -#include "llvm/ADT/SmallVector.h" -#include "llvm/IR/BasicBlock.h" -#include "llvm/IR/CFG.h" -#include "llvm/IR/Constants.h" -#include "llvm/IR/DebugInfoMetadata.h" -#include "llvm/IR/Function.h" -#include "llvm/IR/GlobalVariable.h" -#include "llvm/IR/Instruction.h" -#include "llvm/IR/Instructions.h" -#include "llvm/IR/LLVMContext.h" -#include "llvm/IR/Module.h" -#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 debugLocToString(DebugLoc dbgLoc); - -std::string typeToString(const Type &type); - -void getGlobalFacts(GlobalVariable &G); - -void getFunctionFacts(Function &F); - -void getModuleFacts(Module &M); - -// Embed the accumulated facts into custom ELF sections. -void embedFacts(Module &M); -} // namespace resolve diff --git a/resolve-facts/libs/resolve_facts_llvm/resolve_facts_llvm.cpp b/resolve-facts/libs/resolve_facts_llvm/resolve_facts_llvm.cpp deleted file mode 100644 index d309a60df..000000000 --- a/resolve-facts/libs/resolve_facts_llvm/resolve_facts_llvm.cpp +++ /dev/null @@ -1,183 +0,0 @@ -/* - * Copyright (c) 2025 Riverside Research. - * LGPL-3; See LICENSE.txt in the repo root for details. - */ - -#include "resolve_facts_llvm/resolve_facts_llvm.hpp" - -#include // For std::getenv -#include -#include -#include -#include -#include -#include - -using namespace llvm; - -using Linkage = resolve_facts::Linkage; -using CallType = resolve_facts::CallType; -using EdgeKind = resolve_facts::EdgeKind; - -ProgramFacts resolve::all_facts; -LLVMFacts resolve::facts(resolve::all_facts); - -std::string resolve::debugLocToString(DebugLoc dbgLoc) { - auto line = std::to_string(dbgLoc.getLine()); - auto col = std::to_string(dbgLoc.getCol()); - return line + ":" + col; -} - -std::string resolve::typeToString(const Type &type) { - std::string str; - llvm::raw_string_ostream out(str); - type.print(out); - return str; -} - -void resolve::getGlobalFacts(GlobalVariable &G) { - facts.addNode(G); - facts.addNodeProp(G, [&](auto& node) { - node.name = G.getName().str(); - node.linkage = (G.hasExternalLinkage() ? Linkage::ExternalLinkage : Linkage::Other); - }); -} - -static std::string getFunctionNameFromDebugInfo(Function &F) { - // Each function may have a DISubprogram attached - if (auto *SP = F.getSubprogram()) { - if (auto *File = SP->getFile()) { - return (File->getDirectory() + "/" + File->getFilename()).str(); - } - } - return ""; -} - -void resolve::getFunctionFacts(Function &F) { - facts.addNode(F); - facts.addNodeProp(F, [&](auto& node) { - node.name = F.getName().str(); - node.linkage = (F.hasExternalLinkage() ? Linkage::ExternalLinkage : Linkage::Other); - node.function_type = typeToString(*F.getFunctionType()); - auto name = getFunctionNameFromDebugInfo(F); - if (name != "") { - node.source_file = name; - } - if (F.hasAddressTaken()) { - node.address_taken = true; - } - }); - - if (F.isDeclaration()) - return; - - facts.addEdge(F, F.getEntryBlock(), [](auto& edge) { edge.kinds.push_back(EdgeKind::EntryPoint); }); - - for (Argument &A : F.args()) { - facts.addEdge(F, A, [&](auto& edge) { edge.kinds.push_back(EdgeKind::Contains); }); - facts.addNodeProp(A, [&](auto& node) { node.idx = A.getArgNo(); }); - } - - for (BasicBlock &BB : F) { - facts.addEdge(F, BB, [&](auto& edge) { edge.kinds.push_back(EdgeKind::Contains); }); - facts.addNodeProp(BB, [&](auto& node) { - node.idx = LLVMFacts::getIndexInParent(BB); - if (BB.hasName()) { - node.name = BB.getName().str(); - } - }); - - // Control flow Edges - for (BasicBlock *Succ : successors(&BB)) { - facts.addEdge(BB, *Succ, [&](auto& edge) { edge.kinds.push_back(EdgeKind::ControlFlowTo); }); - } - - for (Instruction &I : BB) { - facts.addEdge(BB, I, [&](auto& edge) { edge.kinds.push_back(EdgeKind::Contains); }); - facts.addNodeProp(I, [&](auto& node) { - node.opcode = I.getOpcodeName(); - if (auto dbgLoc = I.getDebugLoc()) { - node.source_loc = debugLocToString(dbgLoc); - } - }); - - // Data–flow edges: from each operand (if an instruction) to I. - for (Value *op : I.operands()) { - if (Instruction *opI = dyn_cast(op)) { - facts.addEdge(*opI, I, [&](auto& edge) { edge.kinds.push_back(EdgeKind::DataFlowTo); }); - } else if (Argument *opA = dyn_cast(op)) { - facts.addEdge(*opA, I, [&](auto& edge) { edge.kinds.push_back(EdgeKind::DataFlowTo); }); - } else if (GlobalVariable *opG = dyn_cast(op)) { - facts.addEdge(I, *opG, [&](auto& edge) { edge.kinds.push_back(EdgeKind::References); }); - } else if (Function *opF = dyn_cast(op)) { - facts.addEdge(I, *opF, [&](auto& edge) { edge.kinds.push_back(EdgeKind::References); }); - } - } - - // Call edge: record call relationship at the instruction level only. - if (auto *CB = dyn_cast(&I)) { - CallType ct; - if (Function *Callee = CB->getCalledFunction()) { - facts.addEdge(I, *Callee, [&](auto& edge) { edge.kinds.push_back(EdgeKind::Calls); }); - ct = CallType::Direct; - } else { - // Indirect call - ct = CallType::Indirect; - } - - facts.addNodeProp(I, [&](auto& node) { - node.call_type = ct; - node.function_type = typeToString(*CB->getFunctionType()); - }); - } - } - } -} - -void resolve::getModuleFacts(Module &M) { - facts.addNodeProp(M, [&](auto& node) { node.source_file = M.getSourceFileName(); }); - - for (GlobalVariable &G : M.globals()) { - facts.addEdge(M, G, [&](auto& edge) { edge.kinds.push_back(EdgeKind::Contains); }); - - getGlobalFacts(G); - } - - for (Function &F : M) { - facts.addEdge(M, F, [&](auto& edge) { edge.kinds.push_back(EdgeKind::Contains); }); - - getFunctionFacts(F); - } -} - -// Embed the accumulated facts into custom ELF sections. -void resolve::embedFacts(Module &M) { - LLVMContext &C = M.getContext(); - auto embedFactsSection = [&](StringRef sectionName, - const std::string &facts) { - ArrayRef inputData(reinterpret_cast(facts.data()), - facts.size()); - SmallVector compressedFacts; - - if (std::getenv("RESOLVE_IGNORE_COMPRESSION")) { - compressedFacts = SmallVector(inputData); - } else { - compression::Params params(compression::Format::Zstd); - compression::compress(params, inputData, compressedFacts); - } - - //errs() << "Embedding facts for " << sectionName << " with original size " << facts.size() << " and compressed size " << compressedFacts.size() << "\n"; - - Constant *dataArr = ConstantDataArray::get(C, compressedFacts); - GlobalVariable *gv = - new GlobalVariable(M, dataArr->getType(), - /*isConstant=*/true, GlobalValue::InternalLinkage, - dataArr, "resolve" + std::string(sectionName)); - gv->setAlignment(Align()); - gv->setSection(sectionName); - appendToCompilerUsed(M, {gv}); - }; - - // add a newline afterwards to help-distinguish between combined modules - embedFactsSection(".facts", facts.serialize() + "\n"); -} diff --git a/resolve-facts/src/reach/config.hpp b/resolve-facts/src/reach/config.hpp deleted file mode 100644 index 598d00690..000000000 --- a/resolve-facts/src/reach/config.hpp +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright (c) 2025 Riverside Research. - * LGPL-3; See LICENSE.txt in the repo root for details. - */ - -#pragma once - -#include -#include -#include -#include - -#include "json/json.hpp" - -#include "reach/facts.hpp" - -using NNodeId = resolve_facts::NamespacedNodeId; - -namespace conf { -struct query { - NNodeId src; - NNodeId dst; -}; - -struct candidate_node { - std::optional file; - std::string function_name; -}; - -struct config { - std::filesystem::path facts_path; - std::vector queries; - std::vector candidate_path; - bool dynlink = false; - std::optional out_path = {}; - std::optional dlsym_log_path = {}; - std::string graph_type = ""; - std::optional num_paths = {}; - bool validate_facts = false; - bool verbose = false; -}; - -// Generate JSON deserializers for config -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(query, src, dst); -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(candidate_node, file, - function_name); -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(config, facts_path, queries, - candidate_path, dynlink, - out_path, dlsym_log_path, - graph_type, num_paths, - validate_facts, verbose); - -// Load config from JSON file -inline std::optional -load_config_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 conf - -namespace output { -struct path { - std::vector nodes; - std::vector edges; -}; - -struct query_result { - double query_time; - NNodeId src; - NNodeId dst; - std::vector paths; -}; - -struct results { - double facts_load_time; - double graph_build_time; - std::vector query_results; -}; - -// Generate JSON serializers for results -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_ONLY_SERIALIZE(path, nodes, edges) -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_ONLY_SERIALIZE(query_result, query_time, src, - dst, paths); -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_ONLY_SERIALIZE(results, facts_load_time, - graph_build_time, - query_results); -} // namespace output diff --git a/resolve-facts/src/reach/main.cpp b/resolve-facts/src/reach/main.cpp deleted file mode 100644 index d6e1c5cdc..000000000 --- a/resolve-facts/src/reach/main.cpp +++ /dev/null @@ -1,377 +0,0 @@ -/* - * Copyright (c) 2025 Riverside Research. - * LGPL-3; See LICENSE.txt in the repo root for details. - */ - -// reach - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "argparse/argparse.hpp" - -#include "config.hpp" -#include "reach/facts.hpp" -#include "reach/graph.hpp" -#include "reach/search.hpp" -#include "reach/util.hpp" - -using namespace std; -using namespace chrono; -namespace fs = filesystem; -using json = nlohmann::json; - -// Load config from input file if it was given, then allow any -// explicitly given command line arguments to override the input file. -conf::config load_config(const argparse::ArgumentParser &program) { - try { - const optional in_path = program.present("input"); - conf::config conf; - if (in_path.has_value()) { - const auto conf_opt = conf::load_config_from_file(in_path.value()); - if (conf_opt.has_value()) { - conf = conf_opt.value(); - } - } - if (program.present("facts")) { - conf.facts_path = program.get("facts"); - } - // TODO: argument passing with new id format - if (program.present("src") && program.present("dst")) { - auto src_str = program.get("src"); - auto dst_str = program.get("dst"); - auto srcs = util::split(src_str, ','); - auto dsts = util::split(dst_str, ','); - - conf.queries.push_back({{std::stoi(srcs[0]), std::stoi(srcs[1])}, - {std::stoi(dsts[0]), std::stoi(dsts[1])}}); - } - conf.dynlink = program.get("dynlink") || conf.dynlink; - if (program.present("output")) { - conf.out_path = program.present("output"); - } - if (program.present("dlsym-log")) { - conf.dlsym_log_path = program.present("dlsym-log"); - } - if (program.present("graph")) { - conf.graph_type = program.get("graph"); - } else if (conf.graph_type == "") { - conf.graph_type = "cfg"; - } - if (program.present("num-paths")) { - conf.num_paths = program.get("num-paths"); - } else if (!conf.num_paths.has_value()) { - conf.num_paths = 1; - } - conf.validate_facts = - program.get("validate-facts") || conf.validate_facts; - - if (program.present("path")) { - auto path = program.get("path"); - auto path_split = util::split(path, ','); - - for (const auto &p : path_split) { - // split on first ';' only to allow c++ namespaced names - // e.g. graph.cpp;graph::build_from_program_facts - auto idx = p.find(';'); - // No file specified - if (idx == std::string::npos) { - conf.candidate_path.emplace_back(std::nullopt, p); - } else { - auto path = p.substr(0, idx); - auto node = p.substr(idx + 1); - conf.candidate_path.emplace_back(path, node); - } - } - } - - conf.verbose = program.get("verbose") || conf.verbose; - return conf; - } catch (exception &e) { - throw runtime_error("argparse error: " + string(e.what())); - } -} - -optional> -build_loaded_syms(const optional &path) { - if (path.has_value()) { - const auto log_opt = dlsym::load_log_from_file(path.value()); - if (!log_opt.has_value()) { - return {}; - } - const auto log = log_opt.value(); - vector syms; - // Ensure no duplicate entries - for (const auto &sym : log.loaded_symbols) { - if (find(syms.begin(), syms.end(), sym) == syms.end()) { - syms.push_back(sym); - } - } - return {syms}; - } else { - return {}; - } -} - -void validate_config(const conf::config &conf) { - if (!fs::exists(conf.facts_path)) { - cerr << "CONFIG ERROR: facts_path " << conf.facts_path << " doesn't exist." - << endl; - exit(1); - } -} - -void print_config(const conf::config &conf) { - const json j = conf; - cout << setw(4) << j << endl; -} - -int main(int argc, char *argv[]) { - argparse::ArgumentParser program("reach"); - - program.add_argument("-f", "--facts").help("facts file path"); - program.add_argument("-s", "--src").help("source node in graph"); - program.add_argument("-d", "--dst").help("destination node in graph"); - program.add_argument("-i", "--input").help("JSON input path"); - program.add_argument("-o", "--output").help("JSON output path"); - program.add_argument("-dl", "--dynlink") - .help( - "treat functions with external linkage as having their address taken") - .flag(); - program.add_argument("-ds", "--dlsym-log") - .help("path to file containing dlsym log of loaded symbols"); - program.add_argument("-g", "--graph") - .help("graph type (\"simple\", \"cfg\", or \"call\"). Default \"cfg\""); - program.add_argument("-p", "--path") - .help("candidate path of comma-separated function_name or " - "file;function_name"); - program.add_argument("-n", "--num-paths") - .help("number of paths to generate (n shortest)") - .scan<'i', size_t>(); - program.add_argument("--validate-facts") - .help("validate facts database after loading") - .flag(); - program.add_argument("--verbose") - .help("print misc information to stdout") - .flag(); - - try { - program.parse_args(argc, argv); - } catch (const std::exception &err) { - cerr << err.what() << endl; - cerr << program; - exit(1); - } - - conf::config conf = load_config(program); - if (conf.verbose) { - cout << "Loaded config:" << endl; - print_config(conf); - } - validate_config(conf); - const auto loaded_syms = build_loaded_syms(conf.dlsym_log_path); - - // Execute reachability queries. - // First, build graph. - - typedef graph::T (*graph_builder)( - const resolve_facts::ProgramFacts &, bool, - const optional> &); - - const unordered_map graph_builders = { - {"cfg", graph::build_from_program_facts}, - }; - - if (!graph_builders.contains(conf.graph_type)) { - cerr << "unknown graph type: '" << conf.graph_type << endl; - exit(-1); - } - - time_point t0 = system_clock::now(); - ifstream facts(conf.facts_path); - const auto pf = resolve_facts::ProgramFacts::deserialize(facts); - facts.close(); - - duration facts_load_time = system_clock::now() - t0; - - if (conf.verbose) { - - auto nodes = 0; - auto edges = 0; - for (const auto &[_, m] : pf.modules) { - nodes += m.nodes.size(); - edges += m.edges.size(); - } - cout << "Loaded facts in " << facts_load_time.count() - << " seconds. # nodes = " << nodes << " # edges = " << edges << endl; - } - - t0 = system_clock::now(); - const auto g = - graph_builders.at(conf.graph_type)(pf, conf.dynlink, loaded_syms); - duration graph_build_time = system_clock::now() - t0; - - if (conf.verbose) { - auto edges = 0; - for (const auto &[_, e] : g.edges) { - edges += e.size(); - } - - cout << "Loaded graph in " << graph_build_time.count() - << " seconds. # edges = " << edges << endl; - } - if (!graph::wf(g.edges)) { - cerr << "WARNING: graph not well-formed" << endl; - } - - // Then execute queries against the graph and accumulate results. - - output::results res; - res.facts_load_time = facts_load_time.count(); - res.graph_build_time = graph_build_time.count(); - - std::vector candidate_ids; - - auto find_node = [&](const auto &node) -> std::optional { - for (const auto &[mid, m] : pf.modules) { - if (node.file && - !m.nodes.at(mid).source_file.value_or("").contains(*node.file)) { - continue; - } - - for (const auto &[nid, n] : m.nodes) { - if (n.type == NodeType::Function && n.name.has_value()) { - auto name = n.name.value(); - // Try an exact match on the function name - if (name == node.function_name) { - return std::optional{std::make_pair(mid, nid)}; - } - - // If that doesn't work attempt to demangle the name - // Sadly __cxa_demangle either requires a malloced pointer as input, - // or returns a (fresh) malloced pointer as a result. - // Calling free() in 2025 is sad. I tried to be fancy with a - // shared_ptr with a custom deallocator but was getting malloc - // corruption errors. - auto ret = abi::__cxa_demangle(name.c_str(), NULL, NULL, NULL); - - if (ret) { - std::string demangled{ret}; - free(ret); - - if (demangled.contains(node.function_name)) { - return std::optional{std::make_pair(mid, nid)}; - } - } - } - } - } - return std::nullopt; - }; - - for (const auto &p : conf.candidate_path) { - auto id = find_node(p); - if (!id.has_value()) { - cerr << "No matching node found for candidate path node (file: " - << p.file.value_or("") << ", function: " << p.function_name - << ")\n"; - } else { - candidate_ids.push_back(id.value()); - } - } - - if (conf.candidate_path.size() > 1 && candidate_ids.size() < 2) { - cerr << "Candidate path specified but not enough nodes found; path: \n"; - for (const auto &p : conf.candidate_path) { - cerr << "\t file:" << p.file.value_or("") - << ", name: " << p.function_name << "\n"; - } - } else if (candidate_ids.size() >= 2) { - for (auto i = 0; i < candidate_ids.size() - 1; i += 1) { - conf.queries.emplace_back(candidate_ids[i], candidate_ids[i + 1]); - } - } - - for (const auto &q : conf.queries) { - t0 = system_clock::now(); - output::query_result qres; - qres.src = q.src; - qres.dst = q.dst; - - auto print_missing = [&](auto node, auto type) { - cerr << "node " << type << " " << resolve_facts::to_string(node) - << " not found" << endl; - }; - - // The graph may not have any edges from the src as all may be of the form - // (dst -> src) If the explicit edge does not exist at least check that the - // id is found in the total list of nodes - auto has_src = g.edges.contains(q.src) || pf.containsNode(q.src); - auto has_dst = g.edges.contains(q.dst) || pf.containsNode(q.dst); - - if (!has_src) { - print_missing(q.src, "src"); - } - if (!has_dst) { - print_missing(q.dst, "dst"); - } - - // If both src and dst exist, try to find path. - if (has_src && has_dst) { - - const auto paths = - search::k_paths_yen(g.edges, q.dst, q.src, conf.num_paths.value()); - - duration query_time = system_clock::now() - t0; - qres.query_time = query_time.count(); - - vector weights; - for (const auto &p : paths) { - weights.push_back(graph::path_weight(p)); - } - if (!is_sorted(weights.begin(), weights.end())) { - cerr << "WARNING: paths not sorted by weight" << endl; - } - - for (const auto &path : paths) { - vector p_ids; - vector edges; - for (const auto &e : path) { - const auto id = e.node; - p_ids.push_back(id); - edges.push_back(EdgeType_to_string(e.type)); - } - - reverse(p_ids.begin(), p_ids.end()); - reverse(edges.begin(), edges.end()); - edges.pop_back(); - - qres.paths.push_back({p_ids, edges}); - } - } else { - exit(-1); - } - - res.query_results.push_back(qres); - } - - // Dump results object to out_path if it exists, else to stdout. - const json j = res; - if (conf.out_path.has_value()) { - ofstream f(conf.out_path.value()); - f << setw(4) << j << endl; - } else { - cout << setw(4) << j << endl; - } - - // for (const auto& qres : res.query_results) { - // cout << "# paths: " << qres.paths.size() << endl; - // } -} diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 450f5bd93..0e8acf754 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -109,7 +109,7 @@ for file in "$STAGED_PREFIX"/bin/*; do done cmake -E make_directory "$STAGE_DIR/usr/local/bin" -for cmd in "$STAGED_PREFIX"/bin/resolve* "$STAGED_PREFIX"/bin/reach "$STAGED_PREFIX"/bin/resolve_read_props; do +for cmd in "$STAGED_PREFIX"/bin/resolve* "$STAGED_PREFIX"/bin/resolve_read_props; do [ -e "$cmd" ] || continue cmd_name=$(basename "$cmd") ln -sfn "$INSTALL_PREFIX/bin/$cmd_name" "$STAGE_DIR/usr/local/bin/$cmd_name" From c2ae581965632aed25c6604b8d044881ac00787c Mon Sep 17 00:00:00 2001 From: Ryan Zmuda Date: Thu, 20 Aug 2026 08:53:07 -0400 Subject: [PATCH 15/16] docs: update reach and facts --- docs/components/facts.md | 45 ++++++++++++++++++++++++++++++++--- docs/examples/reachability.md | 39 ++++++++++++------------------ examples/reachability/run.sh | 4 +--- 3 files changed, 58 insertions(+), 30 deletions(-) diff --git a/docs/components/facts.md b/docs/components/facts.md index 57796ef6b..212f6fbb3 100644 --- a/docs/components/facts.md +++ b/docs/components/facts.md @@ -1,7 +1,46 @@ # Facts -Fact generation extracts structured information from [LLVM IR](https://llvm.org/docs/LangRef.html). Each fact describes a program node, property, or relationship. +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. -The compiler pass writes a compact binary format. It compresses the data with zstd and embeds it in the ELF `.facts` section. +[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. -The [reach](reach.md) command reads facts from an ELF file or an extracted `.facts` file. The [reachability example](../examples/reachability.md) shows the complete workflow. +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/examples/reachability.md b/docs/examples/reachability.md index 82b902671..1b7ffce08 100644 --- a/docs/examples/reachability.md +++ b/docs/examples/reachability.md @@ -23,7 +23,7 @@ We want to ask **RESOLVE**: starting from `main`, can execution actually reach ` ## A Vulnerability Specification -First, describe the vulnerability in [`vulnerabilities.json`](../concepts/vulnerabilities-json.md). Each entry identifies one affected function, which is called a sink. +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 { @@ -50,32 +50,25 @@ 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. +!!! 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 [REACH] Loaded 1 facts modules from 1 input files. -[REACH] Built a libreach graph with 5 edges. +[REACH] Built a libreach graph with 3 edges. [REACH] Wrote 'out.json'. ``` @@ -93,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))" ] } } @@ -107,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 @@ -128,10 +121,8 @@ 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/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" From f5fcb77b2982813df3bed51f1a635055062dd4b4 Mon Sep 17 00:00:00 2001 From: Ryan Zmuda Date: Thu, 20 Aug 2026 09:35:01 -0400 Subject: [PATCH 16/16] fix: probably never should have committed this ELF --- examples/reachability/main | Bin 16936 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100755 examples/reachability/main diff --git a/examples/reachability/main b/examples/reachability/main deleted file mode 100755 index ef01b5e86987f10d573409e1ee5442f980237459..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16936 zcmeHOeRLGn6@R-SAqdHC0yKPx8IeE&W;P2+jABYYVS|An1PwwMCcB$t>1H?X&O$={ z2*g%WP>CNX+ENs-h^?nqJqOPzHWaJaqm&O@u%b;-Ji&^Eqs3T(?Y%SaZiaEwzBjWkyYJmySv}iou`o&r?E4H$C3a0x!WFxUcm|+^6|oe!Pi9#x z3CcrMnWit%0JK^;i(ULq=Z*@$;? z2ZW@L`4f-~Nl@=G{E+vLP|w4r^yX^zy~USf9_JNtBpw!#cb4DCVm`Mg zB!4#pj}I-k`0qf&ypa1FY#an0e?lH!=J5L(3iBO)Pp;n=h_vOl6&2IG%&Z-(2W7;^{3y6EtfLTWkEPx6a^LpNC;#!bKy@GoZ7`ud zO$jVx9(|yV*8jDT<8vACu=ik#Dj4K88|3M6bl7$wjvW2OlV3ZCybk0O;rd#Vq^6c& zKng2zNRcEaRn^XuJl>GE$ro0wMPnl8RZf?;s;N?K%JfJu_OO_myc z0om_c<7J=@dBZ{fDlZHTmfSc*g=|iBRav<-U7Ri!#PT!pnIqV$I5@%4;0riJ5eFI? zZ-ofsAAbxWXJrwP(>#id^9{q%xtq&g?A{E+VPzY5$LaZ|=SoP^6n*^pC3!EeFu6q2 zlbp&uvotx5HI4c+cHZaw;OVe zn+mII6jYGH zD@cy^Hrg^P2Kd;%j^Zp(v&gi?uFm3*Maa6kPAMZ?8;X-a-j@@NM*BQ)doA%IC@uBi zJLVtxr&oiLw`Va>U0rwfyEatzzm5%M_4^we2l}V#J3q0%UU>nP&YrH<`wjrz*#j}P z*YA(TI~HLyGm{wsGXiD=%m|nfFe6|_z>I(y0W$(-1k4DS5%}Lm0DtGoe#vohg(a!j z#*#9svg{A+v1~kiF#D+Irlz%zZ_km~50n{OpTBfCdVb`sZ>@zNlSVHPX2Lds61M3t z=dx}*@p3`+7e|^__U$-x@o?^Kj!0yc^0(c`7WW4Wr_CQWRyp$A)T6iC#y&JDd)?N1 zPyb=ZZ{7>loUR<%|J!vBtR7WfU%%qbd*&_aZtlJDz7JMsH!K?Y{2RW$M zj=h;LPWiH9-9-=TE_#=?5upb_M^|P z+xOsW6EfaeeslHdojDi0bF5XKZYi?t_>zY!mp#7bjgz(`y(8Q;KPkH>fgOv~`=5xM zShA{f!uTWhck|v#_|^W3;RTz!&tELhn!l@O#Wd%z%$ok>5g#4eJL&UlroNxi7Fu`1 zw9X~y(jHA$-i7%I()ZW@L1cK zI$y@5vJlrx3{L>nVOP*Xz!zIZe8XmO84zpu=Ur+reFWlopbI;D1P)$Q&MW~KfcnFReI|) zj)T_IM^kRviN7ma#wX0g-`Mdt!0b<>(PqFRK>T9I1-Jmq|BOa=0oDN?0K|m9X=Adi zS;#DHcFXt?Lz6c_g(%0p8fVW$qm$8~VyOB{=OoCYpFJQ~1*!Jgsbl8ahObWUV6(=} zoH}EY`tvdJm%+6S?C3X9D5`ZTfa{@6Xv0cV?LW4brzF*YW3GRg>jl7=eyOHMk(ta0 zm=Q1|U`D`HAB-y!WOY~lf+GVXQ6G(iKlnd1nKv=K6ems|P% z#<9Fi`ya>iGOgjq@iOg$r2X8a|JS8x5KXr6&5N`ru@3&v#Dx1GF&*IgE3_hej^l8S zV>piHNI-ic`}n@cpV9B)F60fr4gPc8Ec;knC zr_L8_`8;Y_`MV<>Fp-8?^Z0ZfnV51#XJT5?+(b*#@TNp|JTdW#Q{73!MQajN_YM0K z4!Iq;8Ci5QM{}^n>jSxRd&8iS-OX}?&+k*(89q|j zCu-BTsYBNU{I8mxnkkUi_NPN8Zh97^L)f{fQTYl8ETFlAPgH>$L;Di zxd*DLS+yU-=siX9mp~qG|NfuBKp-O>u2$vnRxZ&AAzK9X*xA@voJqR}GU-+uqxU(< zhpT>5W0O?zo?_@N?b68^ep=^(emd>B(g zmKy#LARWfNgwHGWJ0_6%;6i(vKbkdTFL3(>{J|HvVj22{_89GPvl7{GQxzTu`#3|+ z)`2$N3ZFiPc@75@8bfl6SMo$!TG}OFz^6!ZC?vN_-hdKn2el_CHTi=LvS0Ej!BAL| zBW-w|!FQRNu8#N=kT!(F+Q|sCkQJE<=#$Z@h(_71gxSEs3rT5_+<5dN%oe#ric<_S z?OX*m0V;x*l%&W_SYI-uXAx=jF~tvkWBP=8zM~*P&lEoiiwK1O<`iIUlE&vkbzoTU`8-3p;W7e zNMR);1sfZ~UKl%38$uC=8`SLq(CN%53#XL45MfYxoB0fE_QVWj59YDi{8L(jqAvgk zaaySYU!XC_#E{o7qm#>wHMi|8TK6b(I_pe%Ec+eJB%$EXoW6f4G|w4r}BB! z>f{!mn~C9OXtE+VK#s7zMJUh}y4cYPWuo~vPwQ=C`@evCaji{!T8|TM;(oD@7%Sq_I=Ta7*k$6=`ks*1^I)PTtV5qZh61j& ziBIcmLi#_2+D~?bdx4K@cPi8Roe-XFH5uE_#jF=H7*pcY{||&cpuG=ZZ5%QwS#T%6gOF*XQ=WzugwHLXVs7!bR$n^XY&L_-6 zr7n?Q65)bV&!_bUA^l%T_QwAEfQeW!r9P%oV(h zW!yZbB7SX5qGgCr_%?_suQ&DZ3a`-%p_PyWB*f}8mUpo#Vqj&pV!IsreV&v>xFS_AL25j|4z>5Uk}A