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

nordisk/nornir

crates/nornir-introspect/src/size.rs

//! Per-section + per-symbol + per-crate **size** aggregation over a compiled
//! artifact — the pure-Rust counterpart to Bloaty / cargo-bloat / twiggy, shipped
//! as a warehouse producer (never a shell-out).
//!
//! `object` reads the section table (Bloaty's section view) and the symbol table
//! (cargo-bloat's per-function view); the DWARF symbols already extracted by
//! [`crate::artifact::extract_symbols`] are joined **by mangled name** so every
//! byte that carries debug info lands on the crate + source file/line that
//! produced it. Where a symbol has no DWARF row (a release build, a C dep) the
//! crate is best-effort recovered from the demangled path root, so
//! `bloat --by crate` is useful even without workspace debug info.
//!
//! ROOT-LAW #0: the per-symbol demangle + attribution fan-out rides
//! znippy-zoomies' gatling fork-join pool (index-ordered results), the same
//! primitive [`crate::artifact::map_units_parallel`] uses — never rayon.

use std::collections::HashMap;
use std::fs::File;
use std::path::Path;

use anyhow::{Context, Result};
use memmap2::Mmap;
use object::{Object, ObjectSection, ObjectSymbol, SectionIndex, SymbolSection};
use serde::{Deserialize, Serialize};

use crate::artifact::{self, strip_generics, Symbol};

// ELF `sh_flags` bits (stable ABI constants) — decoded when the section flags
// come back as `SectionFlags::Elf`.
const SHF_WRITE: u64 = 0x1;
const SHF_ALLOC: u64 = 0x2;
const SHF_EXECINSTR: u64 = 0x4;

/// One section's size + placement — the Bloaty section view. `.text`/`.rodata`/
/// `.data`/`.debug_*` split tells debug-info-vs-code at a glance.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SectionSize {
    /// Section name, e.g. `.text`, `.rodata`, `.debug_info`.
    pub name: String,
    /// Section size in bytes (`sh_size` / equivalent).
    pub size_bytes: u64,
    /// Virtual address the section loads at (0 for non-alloc sections).
    pub vaddr: u64,
    /// Byte offset of the section's data in the file (0 when absent).
    pub file_offset: u64,
    /// `SHF_ALLOC` — occupies memory at run time.
    pub alloc: bool,
    /// `SHF_EXECINSTR` — executable code.
    pub exec: bool,
    /// `SHF_WRITE` — writable at run time.
    pub write: bool,
    /// Coarse section kind label (`Text`/`Data`/`ReadOnlyData`/`Debug`/…).
    pub kind: String,
}

/// One symbol's size + attribution — the cargo-bloat / twiggy per-symbol view.
/// Reuses [`crate::artifact::Symbol`]'s crate + source file/line when the symbol
/// carries DWARF; `is_monomorphization` flags a generic instantiation (twiggy
/// `monos`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SymbolSize {
    /// Raw (mangled) symbol name from the symbol table.
    pub name_mangled: String,
    /// Rustc-demangled name with generics, e.g. `foo::Bar<u32>::new`.
    pub name_demangled: String,
    /// Demangled name with generics stripped, e.g. `foo::Bar::new`.
    pub name_demangled_no_generics: String,
    /// Owning crate (DWARF `krate`, else the demangled path root, else empty).
    pub crate_name: String,
    /// Section the symbol lives in (e.g. `.text`), or empty if unresolved.
    pub section: String,
    /// Symbol size in bytes.
    pub size_bytes: u64,
    /// Symbol kind label (`Text`/`Data`/`Unknown`).
    pub kind: String,
    /// `global` / `weak` / `local`.
    pub binding: String,
    /// A generic monomorphization (its demangled name carried `<…>`).
    pub is_monomorphization: bool,
    /// DWARF source file (workspace-relative), if the symbol carried debug info.
    pub source_file: Option<String>,
    /// DWARF source line, if present.
    pub source_line: Option<u32>,
}

/// Per-crate size roll-up (cargo-bloat `--crates`): total bytes + symbol count.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CrateSize {
    /// Crate name (empty = unattributed symbols, lumped together).
    pub crate_name: String,
    /// Sum of member symbol sizes.
    pub size_bytes: u64,
    /// Number of symbols attributed to this crate.
    pub symbol_count: u64,
}

/// The full size breakdown of one artifact.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Sizes {
    /// Total artifact size on disk, bytes.
    pub total_bytes: u64,
    /// Per-section sizes (Bloaty view).
    pub sections: Vec<SectionSize>,
    /// Per-symbol sizes (cargo-bloat / twiggy view).
    pub symbols: Vec<SymbolSize>,
    /// Per-crate roll-up (descending by size).
    pub crates: Vec<CrateSize>,
}

/// Analyse the section + symbol + crate sizes of `path`. When `source_root` is
/// `Some`, DWARF symbols are extracted (scoped to that root) and joined by
/// mangled name so every symbol with debug info is attributed to its crate +
/// source file/line. Gatling-parallel over the symbol table.
pub fn analyze_sizes(path: &Path, source_root: Option<&Path>) -> Result<Sizes> {
    let file = File::open(path).with_context(|| format!("open artifact {}", path.display()))?;
    let mmap = unsafe { Mmap::map(&file) }.with_context(|| format!("mmap {}", path.display()))?;
    let total_bytes = mmap.len() as u64;
    let obj =
        object::File::parse(&*mmap).with_context(|| format!("parse object {}", path.display()))?;

    // ── Sections (Bloaty view) ──
    let mut sections = Vec::new();
    for sec in obj.sections() {
        let name = sec.name().unwrap_or("<unnamed>").to_string();
        let (alloc, exec, write) = section_flags(sec.flags(), sec.kind());
        sections.push(SectionSize {
            name,
            size_bytes: sec.size(),
            vaddr: sec.address(),
            file_offset: sec.file_range().map(|(o, _)| o).unwrap_or(0),
            alloc,
            exec,
            write,
            kind: format!("{:?}", sec.kind()),
        });
    }

    // ── DWARF crate/source attribution map (mangled name → Symbol) ──
    let dwarf: HashMap<String, Symbol> = match source_root {
        Some(root) => artifact::extract_symbols(path, root)
            .unwrap_or_default()
            .into_iter()
            .map(|s| (s.name_mangled.clone(), s))
            .collect(),
        None => HashMap::new(),
    };

    // ── Symbols: collect the raw (cheap-borrow) rows, then fan the expensive
    // demangle + attribution across cores (ROOT-LAW #0 gatling). ──
    let mut raw: Vec<RawSym> = Vec::new();
    for sym in obj.symbols() {
        if sym.is_undefined() {
            continue;
        }
        let Ok(name) = sym.name() else { continue };
        if name.is_empty() {
            continue;
        }
        // Only sized code/data symbols contribute to bloat.
        let size = sym.size();
        if size == 0 {
            continue;
        }
        let section = match sym.section() {
            SymbolSection::Section(idx) => section_name(&obj, idx),
            _ => String::new(),
        };
        let binding = if sym.is_weak() {
            "weak"
        } else if sym.is_global() {
            "global"
        } else {
            "local"
        }
        .to_string();
        raw.push(RawSym {
            name_mangled: name.to_string(),
            size_bytes: size,
            kind: format!("{:?}", sym.kind()),
            section,
            binding,
        });
    }

    let symbols = map_syms_parallel(&raw, &dwarf);

    // ── Per-crate roll-up ──
    let mut by_crate: HashMap<String, (u64, u64)> = HashMap::new();
    for s in &symbols {
        let e = by_crate.entry(s.crate_name.clone()).or_insert((0, 0));
        e.0 += s.size_bytes;
        e.1 += 1;
    }
    let mut crates: Vec<CrateSize> = by_crate
        .into_iter()
        .map(|(crate_name, (size_bytes, symbol_count))| CrateSize {
            crate_name,
            size_bytes,
            symbol_count,
        })
        .collect();
    crates.sort_by(|a, b| {
        b.size_bytes
            .cmp(&a.size_bytes)
            .then(a.crate_name.cmp(&b.crate_name))
    });

    Ok(Sizes {
        total_bytes,
        sections,
        symbols,
        crates,
    })
}

/// A raw symbol row, cheap to collect while the `object::File` borrow is live;
/// the expensive demangle + DWARF join is deferred to the gatling fan-out.
struct RawSym {
    name_mangled: String,
    size_bytes: u64,
    kind: String,
    section: String,
    binding: String,
}

/// Fan the demangle + crate/source attribution across cores, results in input
/// order (deterministic). ROOT-LAW #0: znippy-zoomies gatling, never rayon.
fn map_syms_parallel(raw: &[RawSym], dwarf: &HashMap<String, Symbol>) -> Vec<SymbolSize> {
    if raw.is_empty() {
        return Vec::new();
    }
    let indices: Vec<usize> = (0..raw.len()).collect();
    znippy_zoomies::gatling_forkjoin::gatling_map_balanced(
        &indices,
        0,
        1,
        |_| 1,
        |_, &i| build_symbol_size(&raw[i], dwarf),
    )
}

/// Demangle one symbol and attribute it to a crate + source location.
fn build_symbol_size(r: &RawSym, dwarf: &HashMap<String, Symbol>) -> SymbolSize {
    let demangled = rustc_demangle::try_demangle(&r.name_mangled)
        .map(|d| format!("{:#}", d))
        .unwrap_or_else(|_| r.name_mangled.clone());
    let no_generics = strip_generics(&demangled);
    let is_monomorphization = demangled != no_generics;

    let (crate_name, source_file, source_line) = match dwarf.get(&r.name_mangled) {
        Some(s) => (s.krate.clone(), Some(s.file.clone()), s.line),
        None => {
            // Best-effort: a Rust path's leading segment is its crate.
            let c = if no_generics.contains("::") {
                no_generics.split("::").next().unwrap_or("").to_string()
            } else {
                String::new()
            };
            (c, None, None)
        }
    };

    SymbolSize {
        name_mangled: r.name_mangled.clone(),
        name_demangled: demangled,
        name_demangled_no_generics: no_generics,
        crate_name,
        section: r.section.clone(),
        size_bytes: r.size_bytes,
        kind: r.kind.clone(),
        binding: r.binding.clone(),
        is_monomorphization,
        source_file,
        source_line,
    }
}

/// `(alloc, exec, write)` for a section — from ELF `sh_flags` when present, else
/// inferred from the coarse `SectionKind`.
fn section_flags(flags: object::SectionFlags, kind: object::SectionKind) -> (bool, bool, bool) {
    match flags {
        // object 0.40 (2026-10-02): `sh_flags` is the `elf::SectionFlags` newtype.
        object::SectionFlags::Elf { sh_flags, .. } => (
            sh_flags.0 & SHF_ALLOC != 0,
            sh_flags.0 & SHF_EXECINSTR != 0,
            sh_flags.0 & SHF_WRITE != 0,
        ),
        _ => {
            use object::SectionKind::*;
            match kind {
                Text => (true, true, false),
                Data | UninitializedData | Tls => (true, false, true),
                ReadOnlyData | ReadOnlyString => (true, false, false),
                _ => (false, false, false),
            }
        }
    }
}

fn section_name(obj: &object::File, idx: SectionIndex) -> String {
    obj.section_by_index(idx)
        .ok()
        .and_then(|s| s.name().ok().map(str::to_string))
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Size analysis of the test executable itself: it always carries a symbol
    /// table + DWARF under the `test` profile, so sections, symbols and at least
    /// one DWARF-attributed source location must all be non-empty. Red the moment
    /// the section/symbol walk or the DWARF join regresses.
    #[test]
    fn analyze_sizes_selfexe() {
        let bin = std::env::current_exe().expect("current test exe");
        let sizes = analyze_sizes(&bin, Some(Path::new("/"))).expect("analyze");
        assert!(sizes.total_bytes > 0, "total bytes");
        assert!(
            sizes
                .sections
                .iter()
                .any(|s| s.name == ".text" && s.size_bytes > 0),
            "expected a non-empty .text section"
        );
        assert!(
            sizes.symbols.len() > 50,
            "expected a substantial symbol set"
        );
        assert!(
            sizes.symbols.iter().any(|s| s.source_file.is_some()),
            "expected ≥1 symbol attributed to a DWARF source file"
        );
        assert!(!sizes.crates.is_empty(), "expected a per-crate roll-up");
    }
}