Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/graph/extract/binary.rs

//! Binary (ELF) graph extractor — reconstruct the dependency + symbol graph
//! `G = (V, E)` from compiled artefacts when **no source is checked out**.
//!
//! This is the airgap / vendored-`.so` case (EPIC item 8, on-theme for
//! **Skidbladnir**): third-party shared objects, vendored libs, and airgap
//! bundles arrive as ELF, not as `Cargo.toml`s. We parse them **in pure Rust**
//! (the `object` crate — no `libc`, no shelling out to `readelf`/`nm`/`objdump`)
//! and emit the SAME node/edge types the source extractor uses, tagged
//! [`Provenance::Binary`] so a consumer can tell binary-derived facts from
//! source-derived ones.
//!
//! What we reconstruct, and how it maps onto `G = (V, E)`:
//!
//!  - **Library DAG** — `DT_NEEDED` dynamic entries (who links whom). Each
//!    object is a node `V`; a `DT_NEEDED` entry is an edge `object → needed`.
//!    This is the binary twin of the manifest `path`-dep graph.
//!  - **Symbol provide/need** — `.dynsym`: an object *exports* its defined
//!    global symbols and *needs* its `UND` (undefined) ones. In the
//!    [`WorkspaceGraph`] these become a node's `produces` / `consumes` sets —
//!    exactly the role crate names play for source. Resolving a `UND` symbol to
//!    the object that exports it gives the edge's concrete justification (`via`),
//!    the binary analogue of "crate B produces what crate A consumes".
//!  - **Call edges** *(stretch)* — PLT/GOT dynamic relocations
//!    (`R_*_JUMP_SLOT` / `GLOB_DAT`): each is an inter-object reference to a
//!    `UND` symbol → a call/data edge `object → symbol → provider`.
//!
//! The produced [`BinaryGraph`] converts to the very same
//! [`WorkspaceGraph`](crate::warehouse::dep_graph::WorkspaceGraph)
//! ([`to_workspace_graph`](BinaryGraph::to_workspace_graph)) so **every** graph
//! method — build order, blast radius, `dep_path`, SCC/MFAS — works unchanged on
//! a binary-only constellation, and to [`SymbolRow`] / [`CallEdgeRow`]
//! ([`to_symbol_rows`](BinaryGraph::to_symbol_rows) /
//! [`to_call_edge_rows`](BinaryGraph::to_call_edge_rows)) for the warehouse
//! symbol/call facts, mirroring [`crate::knowledge::symbols`].

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use object::read::elf::{ElfFile, ElfFile32, ElfFile64, FileHeader};
use object::{
    Endianness, FileKind, Object, ObjectSymbol, ObjectSymbolTable, ReadRef, RelocationTarget,
    SymbolKind, elf,
};

use super::Provenance;
use crate::knowledge::symbols::{CallEdgeRow, SymbolRow};
use crate::warehouse::dep_graph::{CrossRepoEdge, DepKind, RepoFacts, WorkspaceGraph};

/// Broad ELF object class — enough to tell an executable from a shared library
/// (the two node shapes a link graph cares about).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectClass {
    /// `ET_EXEC` / `ET_DYN` with an entry point — a program.
    Executable,
    /// `ET_DYN` shared object (`.so`) — the vendored-lib case.
    SharedObject,
    /// `ET_REL` relocatable object (`.o`) — pre-link.
    Relocatable,
    /// Anything else (`ET_CORE`, unknown).
    Other,
}

/// One parsed ELF artefact — a node `V` in the binary graph. Its `exports`
/// (defined global `.dynsym` entries) and `imports` (`UND` entries) are the
/// binary analogue of a crate's `produces` / `consumes`.
#[derive(Debug, Clone)]
pub struct BinaryObject {
    /// The node key: the `DT_SONAME` if present, else the file name. Using the
    /// soname makes a `DT_NEEDED` edge resolve to the right node even when the
    /// on-disk file is a versioned real-name (`libz.so.1.3.1`) behind the
    /// soname (`libz.so.1`).
    pub name: String,
    /// Where the artefact was read from.
    pub path: PathBuf,
    /// `DT_SONAME`, when the object declares one.
    pub soname: Option<String>,
    /// Object class (executable / shared object / …).
    pub class: ObjectClass,
    /// `DT_NEEDED` entries in declaration order — the direct link dependencies.
    pub needed: Vec<String>,
    /// `DT_RUNPATH` / `DT_RPATH` search dirs (`:`-split), when present — the
    /// loader's resolution hints (useful to a resolver, recorded for detail).
    pub runpath: Vec<String>,
    /// Defined, globally-visible `.dynsym` function/object symbols this object
    /// **provides**. Weak defs are included (they still satisfy a `UND`).
    pub exports: BTreeSet<String>,
    /// Undefined (`UND`) `.dynsym` symbols this object **needs** from elsewhere.
    pub imports: BTreeSet<String>,
    /// Provenance marker — always [`Provenance::Binary`] here.
    pub provenance: Provenance,
    /// PLT/GOT dynamic relocations captured for call-edge reconstruction —
    /// `(symbol, reloc-kind-label)`. Folded into [`BinaryGraph::call_edges`] at
    /// graph-assembly time.
    relocs: Vec<(String, String)>,
}

/// Which kind of dependency edge — a hard `DT_NEEDED` link, or a symbol
/// resolution that crosses to a provider the loader would reach transitively.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgeKind {
    /// A direct `DT_NEEDED` link (`from` names `to` in its dynamic section).
    Needed,
    /// A symbol `from` imports is satisfied by `to`, but `to` is not a direct
    /// `DT_NEEDED` of `from` (indirect / transitive resolution).
    Symbol,
}

/// A dependency edge `from → to` in the binary graph — the ELF twin of a
/// [`CrossRepoEdge`]. `via` carries the concretely-resolved symbols that justify
/// the edge (empty when the provider is outside the analysed set).
#[derive(Debug, Clone)]
pub struct BinaryEdge {
    /// The needing object (consumer) node name.
    pub from: String,
    /// The providing object (producer) node name — a soname even when that
    /// object was not itself analysed (e.g. a `DT_NEEDED` on `libc.so.6`).
    pub to: String,
    /// `Needed` (a `DT_NEEDED` link) or `Symbol` (indirect symbol resolution).
    pub kind: EdgeKind,
    /// The symbols `from` imports that `to` exports — the concrete justification
    /// ("which lib satisfies which symbol"). Empty when `to` was not analysed.
    pub via: BTreeSet<String>,
    /// Whether `to` is a node we actually parsed (its `exports` are known).
    pub resolved: bool,
    /// Provenance marker — always [`Provenance::Binary`].
    pub provenance: Provenance,
}

/// A PLT/GOT inter-object call/reference edge (the stretch goal) — an object
/// reaching an external `UND` symbol via a dynamic relocation.
#[derive(Debug, Clone)]
pub struct BinaryCallEdge {
    /// The calling object node name.
    pub from: String,
    /// The callee symbol (mangled `.dynsym` name).
    pub symbol: String,
    /// The relocation kind label (e.g. `"Elf(R_X86_64_JUMP_SLOT)"`).
    pub reloc: String,
    /// The providing object, when the symbol resolved to an analysed export.
    pub resolves_to: Option<String>,
    /// Provenance marker — always [`Provenance::Binary`].
    pub provenance: Provenance,
}

/// The reconstructed binary graph: nodes ([`BinaryObject`]s), dependency edges
/// ([`BinaryEdge`]s), and PLT/GOT call edges ([`BinaryCallEdge`]s). Convert to
/// the shared model with [`to_workspace_graph`](Self::to_workspace_graph) /
/// [`to_symbol_rows`](Self::to_symbol_rows) /
/// [`to_call_edge_rows`](Self::to_call_edge_rows).
#[derive(Debug, Clone, Default)]
pub struct BinaryGraph {
    /// Nodes `V` — the parsed ELF artefacts.
    pub objects: Vec<BinaryObject>,
    /// Dependency edges `E` — `DT_NEEDED` links + resolved symbol edges.
    pub edges: Vec<BinaryEdge>,
    /// PLT/GOT inter-object call edges (stretch).
    pub call_edges: Vec<BinaryCallEdge>,
}

/// Demangle a Rust/`itanium` symbol for display; falls back to the raw name.
/// Used only for the human-facing `SymbolRow.signature` — the graph keys stay
/// the mangled `.dynsym` names so resolution is exact.
fn demangle(name: &str) -> String {
    rustc_demangle::try_demangle(name)
        .map(|d| d.to_string())
        .unwrap_or_else(|_| name.to_string())
}

/// Parse a single ELF artefact into a [`BinaryObject`] node (no cross-object
/// edges — use [`extract_graph`] for those). Pure Rust; never shells out.
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)
}

/// Parse a set of ELF artefacts and resolve the dependency + symbol + call
/// graph across them.
///
/// Edges come in two flavours (see [`EdgeKind`]): every `DT_NEEDED` yields a
/// `Needed` edge (its `via` filled in with the symbols the provider actually
/// exports, when the provider is in the set); any remaining unresolved import
/// satisfied by *another* analysed object yields a `Symbol` edge. A
/// `DT_NEEDED` on a library outside the set (e.g. `libc.so.6` when only
/// `libz.so` was handed in) still produces an (unresolved) edge — the link is
/// known even if the provider's symbols are not.
///
/// An unreadable / non-ELF input is **skipped** (logged to stderr), never fatal
/// to the whole graph — one bad vendored blob must not sink the bundle.
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))
}

/// Assemble edges + call edges from already-parsed objects (the resolution core,
/// split out so tests can feed synthetic objects).
fn assemble(objects: Vec<BinaryObject>) -> BinaryGraph {
    // Export index: symbol → every node that exports it. Lets us resolve a
    // `UND` import to its provider(s) concretely.
    let mut export_index: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
    // Node set (soname AND file name both resolve to the node) so a `DT_NEEDED`
    // on a soname matches an object read from its versioned real-name file.
    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 {
        // Which providers we've already linked to via DT_NEEDED — so the
        // Symbol-edge pass only adds the *indirect* resolutions.
        let mut direct_providers: BTreeSet<String> = BTreeSet::new();

        // ── Library DAG: one edge per DT_NEEDED entry ──
        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());
                    // Concrete justification: the symbols obj imports that this
                    // provider exports.
                    let via: BTreeSet<String> =
                        obj.imports.intersection(&p.exports).cloned().collect();
                    (p.name.clone(), true, via)
                }
                // DT_NEEDED on a lib outside the analysed set — link known,
                // symbols not. Keep the edge (the DAG is still recoverable).
                None => (need.clone(), false, BTreeSet::new()),
            };
            edges.push(BinaryEdge {
                from: obj.name.clone(),
                to: to_name,
                kind: EdgeKind::Needed,
                via,
                resolved,
                provenance: Provenance::Binary,
            });
        }

        // ── Symbol resolution: imports satisfied by a NON-DT_NEEDED provider ──
        // (transitive/indirect — the loader would still reach these). Group the
        // extra resolutions per provider so we emit one Symbol edge each.
        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,
            });
        }

        // ── Call edges (stretch): PLT/GOT relocations → external symbols ──
        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 {
    /// Project onto the shared [`WorkspaceGraph`] so every existing graph
    /// method (build order, blast radius, `dep_path`, SCC/MFAS, …) works on a
    /// binary-only constellation.
    ///
    /// Node `V` → [`RepoFacts`]: `name` = object node name, `produces` =
    /// exported symbols, `consumes` = imported symbols (the direct binary
    /// analogue of a crate's produces/consumes). Edge `E` → [`CrossRepoEdge`]
    /// (`Normal` kind — a link/symbol dependency is always order-gating). The
    /// [`WorkspaceGraph`] itself carries no provenance field; every node/edge's
    /// [`Provenance::Binary`] tag lives on the [`BinaryObject`] / [`BinaryEdge`]
    /// this was built from (and on the [`SymbolRow`]s via
    /// [`to_symbol_rows`](Self::to_symbol_rows)).
    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(),
                },
            );
        }
        // Merge multi-symbol edges between the same (from,to) into one edge so
        // the CrossRepoEdge set is one-per-pair (matches the source builder).
        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)
    }

    /// The provide/need symbol facts as [`SymbolRow`]s (mirrors the source syn
    /// symbol pass) — `item_kind` = `"exported_symbol"` / `"imported_symbol"`,
    /// `visibility` = `"pub"` (export) / `"und"` (import), `crate_name` = the
    /// object node, `file` = the artefact path, `signature` = the demangled
    /// name. The `binary_graph` provenance rides in `module_path`.
    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
    }

    /// The PLT/GOT call edges as [`CallEdgeRow`]s (mirrors the source call
    /// graph) — `caller_path` = the calling object, `callee_ident` = the callee
    /// symbol, `call_kind` = the relocation label. The `binary_graph`
    /// provenance rides in `crate_name` suffix via the object node name only, so
    /// `crate_name` stays the object; the marker is implicit in `call_kind`.
    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()
    }

    /// Imports that no analysed object exports — the graph's dangling needs
    /// (their providers live outside the handed-in set, e.g. `libc` symbols
    /// when only `libz` was analysed). Keyed by object node name.
    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
    }
}

// ─── ELF parsing (pure Rust, `object`) ────────────────────────────────────

/// Detect ELF class (32/64) and dispatch to the generic parser. `Endianness` is
/// a runtime enum in `object`, so one 64-bit and one 32-bit instantiation cover
/// both byte orders. Non-ELF input is a hard error (the caller downgrades it to
/// a skip).
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),
    }
}

/// The generic (class/endian-agnostic) parse: pull `DT_SONAME` / `DT_NEEDED` /
/// `DT_RUNPATH` from the dynamic table, exports/imports from `.dynsym`, and
/// PLT/GOT relocations for call edges — all via the `object` reader.
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();

    // ── Dynamic table: DT_SONAME / DT_NEEDED / DT_RUNPATH (+ DT_RPATH) ──
    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() {
        // `string()` reads the value as an offset into the dynamic string table
        // (`.dynstr`) — valid for the string-typed tags below.
        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));
                }
            }
            _ => {}
        }
    }

    // ── Exports / imports from .dynsym ──
    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,
        };
        // Only FUNC/OBJECT symbols matter for provide/need resolution; skip
        // SECTION/FILE/versioning pseudo-symbols.
        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() {
            // Defined + global (or weak-global) → an export. Weak defs still
            // satisfy a UND import, so `is_global()` (true for weak) is right.
            exports.insert(name.to_string());
        }
    }

    // ── Call edges (stretch): PLT/GOT dynamic relocations → UND symbols ──
    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,
        // A PIE executable is ET_DYN with an entry point; a plain .so is ET_DYN
        // with entry 0. Distinguish so an executable isn't mislabelled a lib.
        elf::ET_DYN if elf.entry() != 0 => ObjectClass::Executable,
        elf::ET_DYN => ObjectClass::SharedObject,
        elf::ET_REL => ObjectClass::Relocatable,
        _ => ObjectClass::Other,
    };

    // Node key: prefer the soname (stable across versioned real-names), else the
    // file name.
    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::*;

    /// A synthetic two-node graph exercising the resolution core without any
    /// real ELF: `app` needs `libfoo.so` and imports `foo_hello`, which
    /// `libfoo.so` exports → a resolved `Needed` edge whose `via` names the
    /// concrete symbol.
    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());
        // malloc has no analysed exporter → dangling need.
        assert!(
            g.unresolved_imports()
                .get("app")
                .unwrap()
                .contains("malloc")
        );
    }

    #[test]
    fn workspace_graph_projection_reuses_blast_radius() {
        // libbar → libfoo (bar imports a foo symbol); a change to libfoo's
        // blast radius must include libbar via the shared graph engine.
        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()
        );
        // produces/consumes carried the exports/imports through.
        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");
    }

    /// An import satisfied by a provider that is NOT a direct `DT_NEEDED` yields a
    /// `Symbol` (indirect) edge — the loader would still reach it transitively — and
    /// the direct link stays a `Needed` edge, so the two edge kinds don't collide.
    #[test]
    fn indirect_import_becomes_a_symbol_edge() {
        let g = assemble(vec![
            // app DT_NEEDED only libfoo, but ALSO imports bar_sym (from libbar).
            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"));
        // Everything resolved → no dangling imports.
        assert!(g.unresolved_imports().is_empty());
    }

    /// A symbol an object both imports and exports must NOT create a self-edge (the
    /// `prov == obj.name` guard) — a node never depends on itself.
    #[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"
        );
    }

    /// PLT/GOT relocations become call edges that resolve to the exporting object,
    /// and project to `CallEdgeRow`s with a `plt:` call-kind marker.
    #[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"
        );
    }
}