nornir 0.5.2

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! 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::{extract_graph, extract_object, ObjectClass};
use nornir::graph::extract::{Provenance, BINARY_GRAPH_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)
}