use std::fs::File;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use memmap2::Mmap;
use object::{Object, ObjectSection};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use super::artifact::{self, Symbol};
use super::callgraph_dwarf::{self, CallEdge};
use crate::index::snapshot::{restore_dir_from_iceberg, snapshot_dir_to_iceberg, SnapshotRef};
use crate::warehouse::iceberg::{IcebergWarehouse, TABLE_DWARF_BLOBS, TABLE_DWARF_SNAPSHOTS};
const SYMBOLS_FILE: &str = "dwarf.json";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DwarfFacts {
pub symbols: Vec<Symbol>,
#[serde(default)]
pub calls: Vec<CallEdge>,
}
impl DwarfFacts {
pub fn lookup(&self, pattern: &str) -> Vec<&Symbol> {
artifact::lookup(&self.symbols, pattern)
}
pub fn defined_in(&self, suffix: &str) -> Vec<&Symbol> {
artifact::defined_in(&self.symbols, suffix)
}
pub fn callers_of(&self, name: &str) -> Vec<String> {
callgraph_dwarf::Callgraph::from_edges(&self.calls).callers_of(name)
}
pub fn callees_of(&self, name: &str) -> Vec<String> {
callgraph_dwarf::Callgraph::from_edges(&self.calls).callees_of(name)
}
pub fn call_path(&self, from: &str, to: &str) -> Option<Vec<String>> {
callgraph_dwarf::Callgraph::from_edges(&self.calls).path_between(from, to)
}
}
pub fn snapshot_facts(
wh: &IcebergWarehouse,
workspace: &str,
repo: &str,
git_sha: &str,
branch: &str,
facts: &DwarfFacts,
cache_dir: &Path,
) -> Result<SnapshotRef> {
std::fs::create_dir_all(cache_dir)
.with_context(|| format!("create dwarf cache dir {}", cache_dir.display()))?;
let json = serde_json::to_vec_pretty(facts).context("serialize dwarf facts")?;
let path = cache_dir.join(SYMBOLS_FILE);
std::fs::write(&path, &json).with_context(|| format!("write {}", path.display()))?;
snapshot_dir_to_iceberg(
wh,
TABLE_DWARF_SNAPSHOTS,
TABLE_DWARF_BLOBS,
workspace,
repo,
git_sha,
branch,
cache_dir,
)
}
pub fn snapshot_dwarf(
wh: &IcebergWarehouse,
workspace: &str,
repo: &str,
git_sha: &str,
branch: &str,
binary_path: &Path,
workspace_root: &Path,
cache_dir: &Path,
) -> Result<SnapshotRef> {
let key = binary_content_key(binary_path).unwrap_or_else(|| format!("sha-{git_sha}"));
if let Some(existing) = lookup_cached_snapshot(wh, repo, &key)? {
return Ok(existing);
}
let symbols = artifact::extract_symbols(binary_path, workspace_root)?;
let calls = callgraph_dwarf::extract_callgraph(binary_path, workspace_root)?;
let facts = DwarfFacts { symbols, calls };
let snap = snapshot_facts(wh, workspace, repo, git_sha, branch, &facts, cache_dir)?;
if let Err(e) = record_cached_snapshot(wh, repo, &key, &snap) {
eprintln!("WARNING: nornir: could not record dwarf build-id index: {e:#}");
}
Ok(snap)
}
pub enum CaptureOutcome {
Reused(SnapshotRef),
Captured { snap: SnapshotRef, symbols: usize, calls: usize },
NoDebug,
}
pub fn capture_dwarf_skipping(
wh: &IcebergWarehouse,
workspace: &str,
repo: &str,
git_sha: &str,
branch: &str,
binary_path: &Path,
workspace_root: &Path,
cache_dir: &Path,
) -> Result<CaptureOutcome> {
let key = binary_content_key(binary_path).unwrap_or_else(|| format!("sha-{git_sha}"));
if let Some(existing) = lookup_cached_snapshot(wh, repo, &key)? {
return Ok(CaptureOutcome::Reused(existing));
}
let symbols = artifact::extract_symbols(binary_path, workspace_root)?;
if symbols.is_empty() {
return Ok(CaptureOutcome::NoDebug);
}
let calls = callgraph_dwarf::extract_callgraph(binary_path, workspace_root).unwrap_or_default();
let (n_symbols, n_calls) = (symbols.len(), calls.len());
let facts = DwarfFacts { symbols, calls };
let snap = snapshot_facts(wh, workspace, repo, git_sha, branch, &facts, cache_dir)?;
if let Err(e) = record_cached_snapshot(wh, repo, &key, &snap) {
eprintln!("WARNING: nornir: could not record dwarf build-id index: {e:#}");
}
Ok(CaptureOutcome::Captured { snap, symbols: n_symbols, calls: n_calls })
}
pub fn load_dwarf(
wh: &IcebergWarehouse,
repo: &str,
sha: Option<&str>,
into: &Path,
) -> Result<DwarfFacts> {
restore_dir_from_iceberg(wh, TABLE_DWARF_SNAPSHOTS, TABLE_DWARF_BLOBS, repo, sha, into)?;
let path = into.join(SYMBOLS_FILE);
let bytes = std::fs::read(&path).with_context(|| format!("read restored {}", path.display()))?;
serde_json::from_slice(&bytes).context("deserialize dwarf facts")
}
#[derive(Serialize, Deserialize)]
struct CachedSnapshot {
repo: String,
key: String,
snapshot_id: String,
workspace: String,
git_sha: String,
branch: String,
schema_hash: String,
blob_count: i32,
total_bytes: i64,
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for &b in bytes {
s.push(HEX[(b >> 4) as usize] as char);
s.push(HEX[(b & 0x0f) as usize] as char);
}
s
}
pub fn binary_content_key(binary_path: &Path) -> Option<String> {
let file = File::open(binary_path).ok()?;
let mmap = unsafe { Mmap::map(&file) }.ok()?;
let obj = object::File::parse(&*mmap).ok()?;
if let Ok(Some(id)) = obj.build_id() {
if !id.is_empty() {
return Some(format!("bid-{}", hex_encode(id)));
}
}
let mut h = Sha256::new();
let mut any = false;
for name in [".debug_info", ".debug_str"] {
if let Some(sec) = obj.section_by_name(name) {
if let Ok(data) = sec.data() {
if !data.is_empty() {
h.update(name.as_bytes());
h.update(b":");
h.update(data);
any = true;
}
}
}
}
if any {
return Some(format!("dbg-{}", hex_encode(&h.finalize())));
}
None
}
fn build_id_marker_path(wh: &IcebergWarehouse, repo: &str, key: &str) -> PathBuf {
let mut h = Sha256::new();
h.update(repo.as_bytes());
h.update(b"\0");
h.update(key.as_bytes());
let fname = format!("{}.json", hex_encode(&h.finalize()));
wh.root().join("dwarf_build_id_index").join(fname)
}
pub fn lookup_cached_snapshot(
wh: &IcebergWarehouse,
repo: &str,
key: &str,
) -> Result<Option<SnapshotRef>> {
let path = build_id_marker_path(wh, repo, key);
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e).with_context(|| format!("read {}", path.display())),
};
let Ok(c) = serde_json::from_slice::<CachedSnapshot>(&bytes) else {
return Ok(None);
};
if c.repo != repo || c.key != key {
return Ok(None);
}
let Ok(snapshot_id) = Uuid::parse_str(&c.snapshot_id) else {
return Ok(None);
};
Ok(Some(SnapshotRef {
snapshot_id,
workspace: c.workspace,
repo: c.repo,
git_sha: c.git_sha,
branch: c.branch,
schema_hash: c.schema_hash,
blob_count: c.blob_count,
total_bytes: c.total_bytes,
}))
}
pub fn record_cached_snapshot(
wh: &IcebergWarehouse,
repo: &str,
key: &str,
snap: &SnapshotRef,
) -> Result<()> {
let path = build_id_marker_path(wh, repo, key);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
let cached = CachedSnapshot {
repo: repo.to_string(),
key: key.to_string(),
snapshot_id: snap.snapshot_id.to_string(),
workspace: snap.workspace.clone(),
git_sha: snap.git_sha.clone(),
branch: snap.branch.clone(),
schema_hash: snap.schema_hash.clone(),
blob_count: snap.blob_count,
total_bytes: snap.total_bytes,
};
let json = serde_json::to_vec_pretty(&cached).context("serialize build-id marker")?;
std::fs::write(&path, &json).with_context(|| format!("write {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::introspect::callgraph_dwarf::CallKind;
fn edge(caller: &str, callee: &str) -> CallEdge {
CallEdge { caller: caller.to_string(), callee: callee.to_string(), kind: CallKind::Inline }
}
fn sym(name: &str, file: &str, krate: &str, line: u32) -> Symbol {
Symbol {
name: name.to_string(),
name_demangled: name.to_string(),
name_mangled: format!("_ZN{name}"),
file: file.to_string(),
line: Some(line),
size_bytes: Some(42),
krate: krate.to_string(),
}
}
#[test]
fn dwarf_facts_round_trip_through_warehouse() {
let root = tempfile::tempdir().expect("tempdir");
let wh = IcebergWarehouse::open(&root.path().join(".nornir/warehouse"))
.expect("open warehouse");
let facts = DwarfFacts {
symbols: vec![
sym("foo::Bar::new", "holger/src/bar.rs", "holger", 10),
sym("foo::baz", "holger/src/lib.rs", "holger", 20),
],
calls: vec![edge("foo::Bar::new", "foo::baz")],
};
let sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let cache = root.path().join(".nornir/cache/dwarf/holger");
let snap = snapshot_facts(&wh, "ws_t", "holger", sha, "main", &facts, &cache)
.expect("snapshot dwarf facts");
assert!(snap.blob_count > 0, "expected at least one blob");
assert_eq!(snap.git_sha, sha);
let into = root.path().join("restore/holger");
let got = load_dwarf(&wh, "holger", None, &into).expect("load dwarf facts");
assert_eq!(got.symbols.len(), 2);
assert_eq!(got.lookup("Bar").len(), 1, "lookup by substring");
assert_eq!(got.defined_in("lib.rs").len(), 1, "defined_in by suffix");
assert_eq!(got.callees_of("foo::Bar::new"), vec!["foo::baz"], "callees");
assert_eq!(got.callers_of("foo::baz"), vec!["foo::Bar::new"], "callers");
}
#[test]
fn dwarf_load_pins_to_sha() {
let root = tempfile::tempdir().expect("tempdir");
let wh = IcebergWarehouse::open(&root.path().join(".nornir/warehouse"))
.expect("open warehouse");
let cache = root.path().join(".nornir/cache/dwarf/r");
let sha1 = "1111111111111111111111111111111111111111";
let f1 = DwarfFacts {
symbols: vec![sym("a::one", "r/src/a.rs", "r", 1)],
..Default::default()
};
snapshot_facts(&wh, "ws", "r", sha1, "main", &f1, &cache).expect("snap1");
let sha2 = "2222222222222222222222222222222222222222";
let f2 = DwarfFacts {
symbols: vec![sym("a::one", "r/src/a.rs", "r", 1), sym("a::two", "r/src/a.rs", "r", 2)],
..Default::default()
};
snapshot_facts(&wh, "ws", "r", sha2, "main", &f2, &cache).expect("snap2");
let into = root.path().join("restore");
let got1 = load_dwarf(&wh, "r", Some(sha1), &into.join("s1")).expect("load sha1");
assert_eq!(got1.symbols.len(), 1, "sha1 snapshot has one symbol");
let got_latest = load_dwarf(&wh, "r", None, &into.join("latest")).expect("load latest");
assert_eq!(got_latest.symbols.len(), 2, "latest snapshot has two symbols");
}
#[test]
fn build_id_index_round_trip() {
let root = tempfile::tempdir().expect("tempdir");
let wh = IcebergWarehouse::open(&root.path().join(".nornir/warehouse"))
.expect("open warehouse");
let cache = root.path().join(".nornir/cache/dwarf/r");
let facts = DwarfFacts {
symbols: vec![sym("a::b", "r/src/a.rs", "r", 1)],
..Default::default()
};
let snap = snapshot_facts(&wh, "ws", "r", "sha0", "main", &facts, &cache).expect("snap");
assert!(lookup_cached_snapshot(&wh, "r", "bid-abc").expect("lookup").is_none());
record_cached_snapshot(&wh, "r", "bid-abc", &snap).expect("record");
let got = lookup_cached_snapshot(&wh, "r", "bid-abc").expect("lookup").expect("hit");
assert_eq!(got.snapshot_id, snap.snapshot_id, "same snapshot id reused");
assert_eq!(got.git_sha, snap.git_sha);
assert_eq!(got.schema_hash, snap.schema_hash);
assert_eq!(got.blob_count, snap.blob_count);
assert_eq!(got.total_bytes, snap.total_bytes);
assert!(lookup_cached_snapshot(&wh, "other", "bid-abc").expect("lookup").is_none());
assert!(lookup_cached_snapshot(&wh, "r", "bid-nope").expect("lookup").is_none());
}
#[test]
fn content_key_is_stable() {
let candidates = [
Path::new(env!("CARGO_MANIFEST_DIR")).join("target/release/nornir"),
Path::new(env!("CARGO_MANIFEST_DIR")).join("target/debug/nornir"),
std::env::current_exe().unwrap_or_default(),
];
let Some(bin) = candidates.into_iter().find(|p| p.exists()) else {
eprintln!("skipping: no binary to key");
return;
};
let k1 = binary_content_key(&bin);
let k2 = binary_content_key(&bin);
assert!(k1.is_some(), "expected a content key for {}", bin.display());
assert_eq!(k1, k2, "content key must be stable across calls");
}
#[test]
fn live_capture_skips_extraction_on_warm_rerun() {
let ws_root = Path::new(env!("CARGO_MANIFEST_DIR"));
let candidates = [
ws_root.join("target/release/nornir"),
ws_root.join("target/debug/nornir"),
std::env::current_exe().unwrap_or_default(),
];
let Some(bin) = candidates.into_iter().find(|p| p.exists()) else {
eprintln!("skipping: no binary to capture");
return;
};
let root = tempfile::tempdir().expect("tempdir");
let wh = IcebergWarehouse::open(&root.path().join(".nornir/warehouse"))
.expect("open warehouse");
let cache = root.path().join(".nornir/cache/dwarf/nornir");
let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let cold = capture_dwarf_skipping(&wh, "ws", "nornir", sha, "main", &bin, ws_root, &cache)
.expect("cold capture");
let cold_id = match cold {
CaptureOutcome::Captured { snap, symbols, .. } => {
assert!(symbols > 0, "cold capture extracted workspace symbols");
snap.snapshot_id
}
CaptureOutcome::NoDebug => {
eprintln!("skipping: {} has no workspace debug symbols to key", bin.display());
return;
}
CaptureOutcome::Reused(_) => panic!("cold capture cannot be a cache hit"),
};
let warm = capture_dwarf_skipping(&wh, "ws", "nornir", sha, "main", &bin, ws_root, &cache)
.expect("warm capture");
match warm {
CaptureOutcome::Reused(snap) => {
assert_eq!(snap.snapshot_id, cold_id, "warm re-run reuses the cold snapshot");
}
other => panic!(
"warm re-run must SKIP via the build-id index, got {}",
match other {
CaptureOutcome::Captured { .. } => "Captured (re-extracted!)",
CaptureOutcome::NoDebug => "NoDebug",
CaptureOutcome::Reused(_) => unreachable!(),
}
),
}
}
}