tests/binary_graph_elf.rs
//! Binary (ELF) graph extractor against a REAL system shared object (EPIC item
//! 8). We parse `libz.so` with the pure-Rust `object` reader — never shelling
//! out to `readelf`/`nm` in the library — and assert the reconstructed graph
//! carries the real `DT_NEEDED` link, the real `DT_SONAME`, and real exported +
//! imported symbols, then that it projects onto the shared `WorkspaceGraph` and
//! carries the `binary_graph` provenance tag.
//!
//! The one `readelf` call here is a **test-only cross-check** (allowed by the
//! task: parse in Rust, shell out only in tests to validate) — skipped when
//! `readelf` is absent. The whole test skips gracefully if no `libz.so` is
//! installed, so it is a real assertion where possible and inert otherwise.
//!
//! EMIT-DOCTRINE: the real assertions are the gate; the trailing
//! `functional_status` is the test-matrix observation (a stripped no-op unless
//! `--features testmatrix`).
use std::path::PathBuf;
use std::process::Command;
use nornir::graph::extract::binary::{ObjectClass, extract_graph, extract_object};
use nornir::graph::extract::{BINARY_GRAPH_PROVENANCE, Provenance};
/// Locate a real `libz.so*` on the host (any distro layout) — the versioned
/// real-name is fine (we read through it). `None` when zlib isn't installed.
fn find_libz() -> Option<PathBuf> {
let dirs = [
"/usr/lib/x86_64-linux-gnu",
"/lib/x86_64-linux-gnu",
"/usr/lib64",
"/lib64",
"/usr/lib",
"/lib",
"/usr/lib/aarch64-linux-gnu",
];
// Prefer the plain `libz.so` / `libz.so.1`, else any `libz.so.*` real file.
for d in dirs {
for cand in ["libz.so", "libz.so.1"] {
let p = PathBuf::from(d).join(cand);
if p.exists() {
return Some(p);
}
}
if let Ok(rd) = std::fs::read_dir(d) {
for e in rd.flatten() {
let name = e.file_name();
let name = name.to_string_lossy();
if name.starts_with("libz.so") {
return Some(e.path());
}
}
}
}
None
}
#[test]
fn extracts_dt_needed_soname_and_symbols_from_real_libz() {
let Some(libz) = find_libz() else {
eprintln!("skip: no libz.so on this host");
return;
};
let obj = extract_object(&libz).expect("libz parses as ELF");
// Provenance + class.
assert_eq!(obj.provenance, Provenance::Binary);
assert_eq!(
obj.class,
ObjectClass::SharedObject,
"libz is a shared object"
);
// DT_SONAME — libz declares `libz.so.1`; the node key adopts the soname.
let soname = obj.soname.clone().expect("libz declares DT_SONAME");
assert!(soname.starts_with("libz.so"), "soname = {soname}");
assert_eq!(obj.name, soname, "node key is the soname");
// DT_NEEDED — libz links libc.
assert!(
obj.needed.iter().any(|n| n.starts_with("libc.so")),
"libz must DT_NEEDED libc, got {:?}",
obj.needed
);
// Exports (provide) — the zlib public API.
for want in ["deflate", "inflate", "compress"] {
assert!(obj.exports.contains(want), "libz must export `{want}`");
}
// Imports (need) — libz calls into libc; UND set must be non-empty.
assert!(!obj.imports.is_empty(), "libz has UND imports from libc");
// Cross-check DT_NEEDED against readelf when available (test-only).
if let Some(readelf_needed) = readelf_needed(&libz) {
for n in &obj.needed {
assert!(
readelf_needed.iter().any(|r| r == n),
"our DT_NEEDED `{n}` must appear in readelf output {readelf_needed:?}"
);
}
}
// ── Graph assembly + shared-model projection ──
let graph = extract_graph(&[libz.clone()]).expect("graph builds");
assert_eq!(graph.objects.len(), 1);
// The DT_NEEDED on libc (outside the analysed set) is kept as an unresolved
// edge — the link DAG is recoverable even without the provider's symbols.
let libc_edge = graph
.edges
.iter()
.find(|e| e.to.starts_with("libc.so"))
.expect("edge to libc kept");
assert!(
!libc_edge.resolved,
"libc wasn't analysed → unresolved edge"
);
// Projects onto the SAME WorkspaceGraph the source extractor feeds.
let wg = graph.to_workspace_graph();
assert!(
wg.has_component(&soname),
"libz is a node in the shared graph"
);
assert!(
wg.facts.get(&soname).unwrap().produces.contains("deflate"),
"exports carried through as `produces`"
);
// Symbol rows carry the binary_graph provenance marker.
let rows = graph.to_symbol_rows();
let deflate = rows
.iter()
.find(|r| r.item_name == "deflate")
.expect("deflate row");
assert_eq!(deflate.item_kind, "exported_symbol");
assert_eq!(
deflate.module_path, BINARY_GRAPH_PROVENANCE,
"provenance tag on the node"
);
let export_rows = rows
.iter()
.filter(|r| r.item_kind == "exported_symbol")
.count();
let import_rows = rows
.iter()
.filter(|r| r.item_kind == "imported_symbol")
.count();
// Test-matrix observation (no-op unless --features testmatrix).
nornir_testmatrix::functional_status(
"graph-binary-extract",
"elf_dt_needed_soname_symbols_reconstructed",
obj.needed.iter().any(|n| n.starts_with("libc.so"))
&& obj.exports.contains("deflate")
&& !obj.imports.is_empty(),
&format!(
"soname={soname} needed={:?} exports={export_rows} imports={import_rows} \
call_edges={}",
obj.needed,
graph.call_edges.len()
),
);
}
#[test]
fn call_edges_from_plt_got_relocations() {
let Some(libz) = find_libz() else {
eprintln!("skip: no libz.so on this host");
return;
};
let graph = extract_graph(&[libz]).expect("graph builds");
// libz's PLT/GOT relocations reference its libc imports — the stretch-goal
// inter-object call edges. A dynamically-linked lib always has some.
assert!(
!graph.call_edges.is_empty(),
"PLT/GOT relocations must yield call edges"
);
// Every call edge names a symbol and carries binary provenance.
for c in &graph.call_edges {
assert!(!c.symbol.is_empty());
assert_eq!(c.provenance, Provenance::Binary);
}
nornir_testmatrix::functional_status(
"graph-binary-extract",
"plt_got_relocations_yield_call_edges",
!graph.call_edges.is_empty(),
&format!("call_edges={}", graph.call_edges.len()),
);
}
/// The `DT_NEEDED` list per `readelf -d` (test-only cross-check). `None` when
/// `readelf` isn't installed or fails.
fn readelf_needed(path: &PathBuf) -> Option<Vec<String>> {
let out = Command::new("readelf").arg("-d").arg(path).output().ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let mut needed = Vec::new();
for line in text.lines() {
if line.contains("(NEEDED)") {
// …Shared library: [libc.so.6]
if let Some(start) = line.find('[') {
if let Some(end) = line[start..].find(']') {
needed.push(line[start + 1..start + end].to_string());
}
}
}
}
Some(needed)
}