use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use object::read::elf::{ElfFile, ElfFile32, ElfFile64, FileHeader};
use object::{
elf, Endianness, FileKind, Object, ObjectSymbol, ObjectSymbolTable, ReadRef, RelocationTarget,
SymbolKind,
};
use super::Provenance;
use crate::knowledge::symbols::{CallEdgeRow, SymbolRow};
use crate::warehouse::dep_graph::{CrossRepoEdge, DepKind, RepoFacts, WorkspaceGraph};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectClass {
Executable,
SharedObject,
Relocatable,
Other,
}
#[derive(Debug, Clone)]
pub struct BinaryObject {
pub name: String,
pub path: PathBuf,
pub soname: Option<String>,
pub class: ObjectClass,
pub needed: Vec<String>,
pub runpath: Vec<String>,
pub exports: BTreeSet<String>,
pub imports: BTreeSet<String>,
pub provenance: Provenance,
relocs: Vec<(String, String)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgeKind {
Needed,
Symbol,
}
#[derive(Debug, Clone)]
pub struct BinaryEdge {
pub from: String,
pub to: String,
pub kind: EdgeKind,
pub via: BTreeSet<String>,
pub resolved: bool,
pub provenance: Provenance,
}
#[derive(Debug, Clone)]
pub struct BinaryCallEdge {
pub from: String,
pub symbol: String,
pub reloc: String,
pub resolves_to: Option<String>,
pub provenance: Provenance,
}
#[derive(Debug, Clone, Default)]
pub struct BinaryGraph {
pub objects: Vec<BinaryObject>,
pub edges: Vec<BinaryEdge>,
pub call_edges: Vec<BinaryCallEdge>,
}
fn demangle(name: &str) -> String {
rustc_demangle::try_demangle(name)
.map(|d| d.to_string())
.unwrap_or_else(|_| name.to_string())
}
pub fn extract_object(path: &Path) -> Result<BinaryObject> {
let data =
std::fs::read(path).with_context(|| format!("reading ELF artefact {}", path.display()))?;
parse_object(path, &data)
}
pub fn extract_graph(paths: &[PathBuf]) -> Result<BinaryGraph> {
let mut objects: Vec<BinaryObject> = Vec::new();
for p in paths {
match extract_object(p) {
Ok(obj) => objects.push(obj),
Err(e) => eprintln!(
"nornir: binary-graph: skipping {} — not a parseable ELF: {e:#}",
p.display()
),
}
}
Ok(assemble(objects))
}
fn assemble(objects: Vec<BinaryObject>) -> BinaryGraph {
let mut export_index: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
let mut node_by_key: BTreeMap<&str, &BinaryObject> = BTreeMap::new();
for obj in &objects {
node_by_key.insert(obj.name.as_str(), obj);
if let Some(so) = &obj.soname {
node_by_key.insert(so.as_str(), obj);
}
for e in &obj.exports {
export_index.entry(e.as_str()).or_default().push(obj.name.as_str());
}
}
let mut edges: Vec<BinaryEdge> = Vec::new();
let mut call_edges: Vec<BinaryCallEdge> = Vec::new();
for obj in &objects {
let mut direct_providers: BTreeSet<String> = BTreeSet::new();
for need in &obj.needed {
let provider = node_by_key.get(need.as_str());
let (to_name, resolved, via) = match provider {
Some(p) => {
direct_providers.insert(p.name.clone());
let via: BTreeSet<String> =
obj.imports.intersection(&p.exports).cloned().collect();
(p.name.clone(), true, via)
}
None => (need.clone(), false, BTreeSet::new()),
};
edges.push(BinaryEdge {
from: obj.name.clone(),
to: to_name,
kind: EdgeKind::Needed,
via,
resolved,
provenance: Provenance::Binary,
});
}
let mut indirect: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
for imp in &obj.imports {
if let Some(providers) = export_index.get(imp.as_str()) {
for &prov in providers {
if prov == obj.name || direct_providers.contains(prov) {
continue;
}
indirect.entry(prov).or_default().insert(imp.clone());
}
}
}
for (prov, via) in indirect {
edges.push(BinaryEdge {
from: obj.name.clone(),
to: prov.to_string(),
kind: EdgeKind::Symbol,
via,
resolved: true,
provenance: Provenance::Binary,
});
}
for (symbol, reloc) in &obj.relocs {
let resolves_to =
export_index.get(symbol.as_str()).and_then(|ps| ps.first().map(|s| s.to_string()));
call_edges.push(BinaryCallEdge {
from: obj.name.clone(),
symbol: symbol.clone(),
reloc: reloc.clone(),
resolves_to,
provenance: Provenance::Binary,
});
}
}
BinaryGraph { objects, edges, call_edges }
}
impl BinaryGraph {
pub fn to_workspace_graph(&self) -> WorkspaceGraph {
let mut facts: BTreeMap<String, RepoFacts> = BTreeMap::new();
for obj in &self.objects {
facts.insert(
obj.name.clone(),
RepoFacts {
name: obj.name.clone(),
root: obj.path.clone(),
produces: obj.exports.clone(),
consumes: obj.imports.clone(),
dev_only_consumes: BTreeSet::new(),
},
);
}
let mut merged: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
for e in &self.edges {
merged.entry((e.from.clone(), e.to.clone())).or_default().extend(e.via.iter().cloned());
}
let edges = merged
.into_iter()
.map(|((from, to), via)| CrossRepoEdge { from, to, via, kind: DepKind::Normal })
.collect();
WorkspaceGraph::from_query_parts(facts, edges)
}
pub fn to_symbol_rows(&self) -> Vec<SymbolRow> {
let mut rows = Vec::new();
for obj in &self.objects {
let file = obj.path.display().to_string();
for sym in &obj.exports {
rows.push(SymbolRow {
crate_name: obj.name.clone(),
module_path: Provenance::Binary.as_str().to_string(),
item_kind: "exported_symbol".to_string(),
item_name: sym.clone(),
visibility: "pub".to_string(),
file: file.clone(),
line: 0,
doc_lines: 0,
signature: Some(demangle(sym)),
});
}
for sym in &obj.imports {
rows.push(SymbolRow {
crate_name: obj.name.clone(),
module_path: Provenance::Binary.as_str().to_string(),
item_kind: "imported_symbol".to_string(),
item_name: sym.clone(),
visibility: "und".to_string(),
file: file.clone(),
line: 0,
doc_lines: 0,
signature: Some(demangle(sym)),
});
}
}
rows
}
pub fn to_call_edge_rows(&self) -> Vec<CallEdgeRow> {
self.call_edges
.iter()
.map(|c| CallEdgeRow {
crate_name: c.from.clone(),
caller_path: c.from.clone(),
callee_ident: c.symbol.clone(),
call_kind: format!("plt:{}", c.reloc),
file: c.resolves_to.clone().unwrap_or_default(),
line: 0,
})
.collect()
}
pub fn unresolved_imports(&self) -> BTreeMap<String, BTreeSet<String>> {
let mut exported: BTreeSet<&str> = BTreeSet::new();
for obj in &self.objects {
exported.extend(obj.exports.iter().map(String::as_str));
}
let mut out = BTreeMap::new();
for obj in &self.objects {
let missing: BTreeSet<String> =
obj.imports.iter().filter(|i| !exported.contains(i.as_str())).cloned().collect();
if !missing.is_empty() {
out.insert(obj.name.clone(), missing);
}
}
out
}
}
fn parse_object(path: &Path, data: &[u8]) -> Result<BinaryObject> {
match FileKind::parse(data).context("detecting file kind")? {
FileKind::Elf64 => {
let elf = ElfFile64::<Endianness>::parse(data).context("parsing ELF64")?;
from_elf(path, &elf)
}
FileKind::Elf32 => {
let elf = ElfFile32::<Endianness>::parse(data).context("parsing ELF32")?;
from_elf(path, &elf)
}
other => bail!("{} is not an ELF file (kind {:?})", path.display(), other),
}
}
fn from_elf<'data, Elf, R>(path: &Path, elf: &ElfFile<'data, Elf, R>) -> Result<BinaryObject>
where
Elf: FileHeader<Endian = Endianness>,
R: ReadRef<'data>,
{
let endian = elf.endian();
let mut soname: Option<String> = None;
let mut needed: Vec<String> = Vec::new();
let mut runpath: Vec<String> = Vec::new();
let dyn_table = elf.elf_dynamic_table().context("reading ELF dynamic table")?;
for d in dyn_table.iter() {
let read = |d| dyn_table.string(d).ok().map(|b| String::from_utf8_lossy(b).into_owned());
match d.tag {
elf::DT_SONAME => soname = read(d),
elf::DT_NEEDED => {
if let Some(s) = read(d) {
needed.push(s);
}
}
elf::DT_RUNPATH | elf::DT_RPATH => {
if let Some(s) = read(d) {
runpath.extend(s.split(':').filter(|p| !p.is_empty()).map(str::to_string));
}
}
_ => {}
}
}
let mut exports: BTreeSet<String> = BTreeSet::new();
let mut imports: BTreeSet<String> = BTreeSet::new();
for sym in elf.dynamic_symbols() {
let name = match sym.name() {
Ok(n) if !n.is_empty() => n,
_ => continue,
};
if !matches!(sym.kind(), SymbolKind::Text | SymbolKind::Data | SymbolKind::Unknown) {
continue;
}
if sym.is_undefined() {
imports.insert(name.to_string());
} else if sym.is_global() {
exports.insert(name.to_string());
}
}
let mut relocs: Vec<(String, String)> = Vec::new();
if let (Some(reloc_iter), Some(dynsyms)) =
(elf.dynamic_relocations(), elf.dynamic_symbol_table())
{
for (_addr, reloc) in reloc_iter {
if let RelocationTarget::Symbol(idx) = reloc.target() {
if let Ok(sym) = dynsyms.symbol_by_index(idx) {
if let Ok(name) = sym.name() {
if !name.is_empty() {
relocs.push((name.to_string(), format!("{:?}", reloc.kind())));
}
}
}
}
}
}
let class = match elf.elf_header().e_type(endian) {
elf::ET_EXEC => ObjectClass::Executable,
elf::ET_DYN if elf.entry() != 0 => ObjectClass::Executable,
elf::ET_DYN => ObjectClass::SharedObject,
elf::ET_REL => ObjectClass::Relocatable,
_ => ObjectClass::Other,
};
let name = soname.clone().unwrap_or_else(|| {
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| {
path.display().to_string()
})
});
Ok(BinaryObject {
name,
path: path.to_path_buf(),
soname,
class,
needed,
runpath,
exports,
imports,
provenance: Provenance::Binary,
relocs,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn synth(name: &str, needed: &[&str], exports: &[&str], imports: &[&str]) -> BinaryObject {
BinaryObject {
name: name.to_string(),
path: PathBuf::from(format!("/synthetic/{name}")),
soname: Some(name.to_string()),
class: ObjectClass::SharedObject,
needed: needed.iter().map(|s| s.to_string()).collect(),
runpath: Vec::new(),
exports: exports.iter().map(|s| s.to_string()).collect(),
imports: imports.iter().map(|s| s.to_string()).collect(),
provenance: Provenance::Binary,
relocs: Vec::new(),
}
}
#[test]
fn resolves_needed_edge_to_the_exporting_object() {
let g = assemble(vec![
synth("app", &["libfoo.so"], &[], &["foo_hello"]),
synth("libfoo.so", &[], &["foo_hello"], &[]),
]);
let e = g
.edges
.iter()
.find(|e| e.from == "app" && e.to == "libfoo.so")
.expect("app → libfoo.so edge");
assert_eq!(e.kind, EdgeKind::Needed);
assert!(e.resolved, "provider is in the set");
assert!(e.via.contains("foo_hello"), "edge justified by the concrete symbol");
}
#[test]
fn dt_needed_outside_the_set_is_an_unresolved_edge() {
let g = assemble(vec![synth("app", &["libc.so.6"], &[], &["malloc"])]);
let e = g.edges.iter().find(|e| e.to == "libc.so.6").expect("edge to libc kept");
assert!(!e.resolved, "provider not analysed");
assert!(e.via.is_empty());
assert!(g.unresolved_imports().get("app").unwrap().contains("malloc"));
}
#[test]
fn workspace_graph_projection_reuses_blast_radius() {
let g = assemble(vec![
synth("libbar.so", &["libfoo.so"], &["bar_do"], &["foo_hello"]),
synth("libfoo.so", &[], &["foo_hello"], &[]),
]);
let wg = g.to_workspace_graph();
assert_eq!(
wg.dependents_transitive("libfoo.so"),
["libbar.so"].iter().map(|s| s.to_string()).collect()
);
assert!(wg.facts.get("libfoo.so").unwrap().produces.contains("foo_hello"));
assert!(wg.facts.get("libbar.so").unwrap().consumes.contains("foo_hello"));
}
#[test]
fn symbol_rows_tag_binary_provenance() {
let g = assemble(vec![synth("libfoo.so", &[], &["foo_hello"], &["malloc"])]);
let rows = g.to_symbol_rows();
let exp = rows.iter().find(|r| r.item_name == "foo_hello").unwrap();
assert_eq!(exp.item_kind, "exported_symbol");
assert_eq!(exp.module_path, super::super::BINARY_GRAPH_PROVENANCE);
let imp = rows.iter().find(|r| r.item_name == "malloc").unwrap();
assert_eq!(imp.item_kind, "imported_symbol");
assert_eq!(imp.visibility, "und");
}
#[test]
fn indirect_import_becomes_a_symbol_edge() {
let g = assemble(vec![
synth("app", &["libfoo.so"], &[], &["foo_sym", "bar_sym"]),
synth("libfoo.so", &[], &["foo_sym"], &[]),
synth("libbar.so", &[], &["bar_sym"], &[]),
]);
let needed = g.edges.iter().find(|e| e.to == "libfoo.so").expect("direct link");
assert_eq!(needed.kind, EdgeKind::Needed);
assert!(needed.via.contains("foo_sym"));
let sym = g.edges.iter().find(|e| e.to == "libbar.so").expect("indirect link");
assert_eq!(sym.kind, EdgeKind::Symbol, "no DT_NEEDED on libbar ⇒ Symbol edge");
assert!(sym.resolved && sym.via.contains("bar_sym"));
assert!(g.unresolved_imports().is_empty());
}
#[test]
fn self_provided_symbol_makes_no_edge() {
let g = assemble(vec![synth("solo.so", &[], &["s"], &["s"])]);
assert!(g.edges.is_empty(), "an object that provides its own import has no dependency edge");
}
#[test]
fn plt_relocations_resolve_and_project_to_call_edge_rows() {
let mut app = synth("app", &["libc.so.6"], &[], &["malloc"]);
app.relocs = vec![("malloc".to_string(), "Elf(R_X86_64_JUMP_SLOT)".to_string())];
let g = assemble(vec![app, synth("libc.so.6", &[], &["malloc"], &[])]);
assert_eq!(g.call_edges.len(), 1);
let ce = &g.call_edges[0];
assert_eq!(ce.from, "app");
assert_eq!(ce.symbol, "malloc");
assert_eq!(ce.resolves_to.as_deref(), Some("libc.so.6"), "malloc resolves to its exporter");
assert_eq!(ce.provenance, Provenance::Binary);
let rows = g.to_call_edge_rows();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].caller_path, "app");
assert_eq!(rows[0].callee_ident, "malloc");
assert!(rows[0].call_kind.starts_with("plt:"), "call_kind carries the reloc label");
assert_eq!(rows[0].file, "libc.so.6", "resolved provider recorded in the row");
}
}