plugins/native/znippy-plugin-elf/src/facts.rs
//! Every fact this plugin writes, read **from the bytes of the binary**.
//!
//! Law **E-0** (`.nornir/elf-design.md` §2): a path may widen candidacy, it may
//! never assert a fact. Nothing in this module takes a path. The single input is
//! `&[u8]`, and the single gate is the four magic bytes.
//!
//! Law **P-4**: nothing here panics and nothing here returns `Err`. A truncated,
//! hostile or merely unusual binary yields an [`ElfFacts`] with the fields it
//! could prove and `None` everywhere else. There is no `unwrap` on parsed data
//! in this file.
use object::elf;
use object::read::elf::{Dyn, FileHeader, ProgramHeader, SectionHeader};
use object::Endianness;
/// The four bytes that make a file an ELF. Read from the data, never inferred.
pub const ELF_MAGIC: [u8; 4] = [0x7f, b'E', b'L', b'F'];
/// The linker section a nordisk-built artifact carries its build-thing receipt
/// in — `nornir_build_thing::contract::RECEIPT_SECTION`.
pub const RECEIPT_SECTION: &str = ".nordisk.buildthing";
/// The receipt schema this reader understands
/// (`nornir_build_thing::contract::RECEIPT_VERSION`). A receipt from the future
/// is refused rather than interpreted — a misread receipt answers the provenance
/// question *wrongly*, which is worse than not answering it.
pub const RECEIPT_VERSION: u32 = 1;
/// cargo-auditable's embedded dependency tree: zlib-compressed JSON.
pub const DEP_V0_SECTION: &str = ".dep-v0";
/// The systemd/Fedora ELF package-metadata note: owner `FDO`, type `0xcafe1a7e`,
/// payload a NUL-terminated JSON object.
pub const NOTE_PACKAGE_TYPE: u32 = 0xcafe_1a7e;
/// The note owner string for [`NOTE_PACKAGE_TYPE`].
pub const NOTE_PACKAGE_OWNER: &[u8] = b"FDO";
/// A hard ceiling on an inflated `.dep-v0` tree. A section claiming to expand to
/// gigabytes is a decompression bomb, and P-4 says a bomb yields a null column,
/// not an OOM.
const DEP_V0_MAX: usize = 8 * 1024 * 1024;
/// Is this an ELF at all? The only gate. Four bytes, no guessing.
pub fn is_elf(data: &[u8]) -> bool {
data.len() >= 4 && data[..4] == ELF_MAGIC
}
/// The recipe half of a binary's self-description — read from the
/// `.nordisk.buildthing` receipt first, then from `.note.package`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Recipe {
/// `krate:version:hash` — `Receipt.build_thing`, the recipe's own identity.
pub coordinate: Option<String>,
/// What was built (`Receipt.atom`, else `.note.package` `variant`).
pub variant: Option<String>,
/// Resolved settings / features, sorted, space-separated.
pub features: Option<String>,
/// `exe` / `container` / `iso-installer` — `Receipt.target`.
pub target: Option<String>,
/// The toolchain container image reference (no digest).
pub toolchain_image: Option<String>,
/// Its manifest digest: bare lowercase hex, no `sha256:` prefix.
pub toolchain_image_sha256: Option<String>,
/// The VCS revision the build was cut from.
pub source_commit: Option<String>,
}
impl Recipe {
/// True when the binary said nothing at all about how it was built.
pub fn is_empty(&self) -> bool {
*self == Recipe::default()
}
}
/// Everything read out of one ELF.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ElfFacts {
pub member_kind: &'static str,
pub class: &'static str,
pub machine: String,
pub osabi: String,
pub etype: &'static str,
pub interp: Option<String>,
pub abi_level: Option<String>,
pub build_id: Option<String>,
pub needed: Option<String>,
pub text_bytes: u64,
pub debug_bytes: u64,
pub has_debug: bool,
pub recipe: Recipe,
pub auditable_root: Option<String>,
}
/// Read every fact from `data`. `None` when the bytes are not an ELF — which is
/// the plugin declining to claim the member, not an error.
pub fn read(data: &[u8]) -> Option<ElfFacts> {
if !is_elf(data) {
return None;
}
// e_ident is fixed by the spec at offset 0..16 for BOTH classes, so class and
// osabi are readable before anything else is trusted.
let class = match data.get(4)? {
1 => "ELF32",
2 => "ELF64",
_ => return None,
};
let osabi = osabi_name(elf::OsAbi(*data.get(7)?));
// Dispatch on the class ONCE; everything below is generic over the header.
let mut facts = if class == "ELF64" {
read_generic::<elf::FileHeader64<Endianness>>(data)
} else {
read_generic::<elf::FileHeader32<Endianness>>(data)
}?;
facts.class = class;
facts.osabi = osabi;
Some(facts)
}
fn read_generic<Elf: FileHeader<Endian = Endianness>>(data: &[u8]) -> Option<ElfFacts> {
let header = Elf::parse(data).ok()?;
let endian = header.endian().ok()?;
let machine = machine_name(header.e_machine(endian)).to_string();
let etype = type_name(header.e_type(endian));
let mut facts = ElfFacts {
member_kind: "relocatable",
class: "ELF64",
machine,
osabi: String::new(),
etype,
interp: None,
abi_level: None,
build_id: None,
needed: None,
text_bytes: 0,
debug_bytes: 0,
has_debug: false,
recipe: Recipe::default(),
auditable_root: None,
};
// ── program headers: PT_INTERP ────────────────────────────────────────────
if let Ok(segments) = header.program_headers(endian, data) {
for seg in segments {
if seg.p_type(endian) == elf::PT_INTERP {
if let Ok(bytes) = seg.data(endian, data) {
facts.interp = cstr(bytes);
}
}
}
}
// ── sections ──────────────────────────────────────────────────────────────
let sections = match header.sections(endian, data) {
Ok(s) => s,
// No section table: the header facts still stand. P-4.
Err(_) => {
facts.member_kind = member_kind(etype, facts.interp.is_some(), false, false);
return Some(facts);
}
};
let mut has_modinfo = false;
let mut has_debuglink = false;
for section in sections.iter() {
let name = sections.section_name(endian, section).unwrap_or(b"");
let size = section.sh_size(endian).into();
let flags: u64 = section.sh_flags(endian).0.into();
// text: allocated AND executable. `SHF_ALLOC` alone would count rodata.
if flags & elf::SHF_ALLOC.0 != 0 && flags & elf::SHF_EXECINSTR.0 != 0 {
facts.text_bytes = facts.text_bytes.saturating_add(size);
}
if name.starts_with(b".debug_") || name.starts_with(b".zdebug_") {
facts.debug_bytes = facts.debug_bytes.saturating_add(size);
facts.has_debug = true;
}
if name == b".gnu_debuglink" {
has_debuglink = true;
}
if name == b".modinfo" {
has_modinfo = true;
}
if name == RECEIPT_SECTION.as_bytes() {
if let Ok(bytes) = section.data(endian, data) {
read_receipt_into(bytes, &mut facts.recipe);
}
}
if name == DEP_V0_SECTION.as_bytes() {
if let Ok(bytes) = section.data(endian, data) {
facts.auditable_root = read_dep_v0_root(bytes);
}
}
// notes
if let Ok(Some(mut notes)) = section.notes(endian, data) {
while let Ok(Some(note)) = notes.next() {
let owner = note.name();
let ntype = note.n_type(endian);
if owner == b"GNU" && ntype == elf::NT_GNU_BUILD_ID {
facts.build_id = Some(hex(note.desc()));
} else if owner == b"GNU" && ntype == elf::NT_GNU_PROPERTY_TYPE_0 {
if let Some(level) =
abi_level_from_property(note.desc(), endian, header.is_class_64())
{
facts.abi_level = Some(level);
}
} else if owner == NOTE_PACKAGE_OWNER && ntype == elf::NoteType(NOTE_PACKAGE_TYPE) {
read_note_package_into(note.desc(), &mut facts.recipe);
}
}
}
}
facts.has_debug = facts.has_debug || has_debuglink;
// ── DT_NEEDED, augmented with .gnu.version_r ──────────────────────────────
facts.needed = read_needed::<Elf>(endian, data, §ions);
facts.member_kind = member_kind(etype, facts.interp.is_some(), has_modinfo, facts.has_debug);
Some(facts)
}
/// `DT_NEEDED` sonames, each carrying the highest symbol-version it demands
/// from that library, sorted, space-separated:
/// `libc.so.6(GLIBC_2.34) libssl.so.3(OPENSSL_3.0.0)`.
///
/// The soname alone gives the ABI major; `.gnu.version_r` gives the minor the
/// binary actually requires. "Which binaries link libssl 3.0.x" needs both.
fn read_needed<Elf: FileHeader<Endian = Endianness>>(
endian: Endianness,
data: &[u8],
sections: &object::read::elf::SectionTable<'_, Elf>,
) -> Option<String> {
let (dynamic, strtab_index) = sections.dynamic(endian, data).ok()??;
let strings = sections.strings(endian, data, strtab_index).ok()?;
// soname -> the version strings required from it
let mut needed: Vec<(String, Vec<String>)> = Vec::new();
for d in dynamic {
if d.tag(endian) == elf::DT_NEEDED {
if let Ok(name) = d.string(endian, strings) {
needed.push((String::from_utf8_lossy(name).into_owned(), Vec::new()));
}
}
if d.tag(endian) == elf::DT_NULL {
break;
}
}
if needed.is_empty() {
return None;
}
if let Ok(Some((mut verneeds, link))) = sections.gnu_verneed(endian, data) {
if let Ok(vstrings) = sections.strings(endian, data, link) {
while let Ok(Some((verneed, mut vernauxs))) = verneeds.next() {
let file = verneed
.file(endian, vstrings)
.map(|f| String::from_utf8_lossy(f).into_owned())
.unwrap_or_default();
let mut versions: Vec<String> = Vec::new();
while let Ok(Some(aux)) = vernauxs.next() {
if let Ok(n) = aux.name(endian, vstrings) {
versions.push(String::from_utf8_lossy(n).into_owned());
}
}
versions.sort();
if let Some(slot) = needed.iter_mut().find(|(s, _)| *s == file) {
slot.1 = versions;
}
}
}
}
let mut out: Vec<String> = needed
.into_iter()
.map(|(soname, versions)| match versions.last() {
Some(v) => format!("{soname}({v})"),
None => soname,
})
.collect();
out.sort();
Some(out.join(" "))
}
/// `.nordisk.buildthing` — plain serde JSON written by
/// `nornir_build_thing::receipt::encode`.
///
/// This reads the documented JSON shape rather than linking
/// `nornir-build-thing`: that crate pulls `cargo_metadata`, `toml`, `chrono` and
/// `gix`, and znippy has no dependency arrow into edda at all. The three rules
/// its own `decode` states are honoured here verbatim — trailing NUL padding is
/// expected, an INTERIOR NUL means two writers each contributed a receipt and is
/// refused rather than resolved by picking one, and a `receipt_version` above
/// [`RECEIPT_VERSION`] is refused rather than partially interpreted.
fn read_receipt_into(bytes: &[u8], recipe: &mut Recipe) {
let trimmed = match bytes.iter().rposition(|b| *b != 0) {
Some(end) => &bytes[..=end],
None => return,
};
if trimmed.contains(&0) {
// Two receipts in one artifact: which recipe built it is exactly the
// question the receipt exists to answer, so answer neither.
return;
}
let Ok(v) = serde_json::from_slice::<serde_json::Value>(trimmed) else { return };
match v.get("receipt_version").and_then(serde_json::Value::as_u64) {
Some(n) if n <= u64::from(RECEIPT_VERSION) => {}
_ => return,
}
if let Some(bt) = v.get("build_thing") {
let krate = bt.get("krate").and_then(|x| x.as_str());
let version = bt.get("version").and_then(|x| x.as_str());
let hash = bt.get("hash").and_then(|x| x.as_str());
recipe.coordinate = match (krate, version, hash) {
(Some(k), Some(ver), Some(h)) => Some(format!("{k}:{ver}:{h}")),
(Some(k), Some(ver), None) => Some(format!("{k}:{ver}")),
_ => None,
};
}
recipe.variant = str_field(&v, "atom");
recipe.target = str_field(&v, "target");
if let Some(map) = v.get("settings").and_then(serde_json::Value::as_object) {
let mut on: Vec<String> = map
.iter()
.map(|(k, val)| match val.as_str() {
Some(s) => format!("{k}={s}"),
None => format!("{k}={val}"),
})
.collect();
on.sort();
if !on.is_empty() {
recipe.features = Some(on.join(" "));
}
}
if let Some(map) = v.get("inputs").and_then(serde_json::Value::as_object) {
for key in ["commit", "source_commit", "vcs_rev"] {
if recipe.source_commit.is_none() {
recipe.source_commit = map.get(key).and_then(|x| x.as_str()).map(str::to_owned);
}
}
if recipe.toolchain_image.is_none() {
recipe.toolchain_image =
map.get("toolchain_image").and_then(|x| x.as_str()).map(str::to_owned);
}
if recipe.toolchain_image_sha256.is_none() {
recipe.toolchain_image_sha256 = map
.get("toolchain_image_sha256")
.or_else(|| map.get("image_sha256"))
.and_then(|x| x.as_str())
.map(str::to_owned);
}
// `toolchain_image` may arrive as the full `name@sha256:hex` ref that
// `CargoToolchain::image_ref()` produces. Split it rather than store a
// ref where a digest column is expected — but never NORMALISE the
// difference away: what was found is what is written.
if let Some(r) = recipe.toolchain_image.clone() {
if let Some((name, digest)) = r.split_once("@sha256:") {
recipe.toolchain_image = Some(name.to_string());
if recipe.toolchain_image_sha256.is_none() {
recipe.toolchain_image_sha256 = Some(digest.to_string());
}
}
}
}
}
/// `.note.package` — the systemd/Fedora ELF package note. Read as the SECOND
/// source: it only fills what the receipt left null, so a nordisk receipt always
/// wins over a packager's note.
fn read_note_package_into(desc: &[u8], recipe: &mut Recipe) {
let trimmed = match desc.iter().rposition(|b| *b != 0) {
Some(end) => &desc[..=end],
None => return,
};
let Ok(v) = serde_json::from_slice::<serde_json::Value>(trimmed) else { return };
if recipe.variant.is_none() {
recipe.variant = str_field(&v, "variant");
}
if recipe.features.is_none() {
match v.get("features") {
Some(serde_json::Value::Array(a)) => {
let mut f: Vec<String> =
a.iter().filter_map(|x| x.as_str().map(str::to_owned)).collect();
f.sort();
if !f.is_empty() {
recipe.features = Some(f.join(" "));
}
}
Some(serde_json::Value::String(s)) => recipe.features = Some(s.clone()),
_ => {}
}
}
if recipe.source_commit.is_none() {
recipe.source_commit =
str_field(&v, "sourceCommit").or_else(|| str_field(&v, "source_commit"));
}
if recipe.toolchain_image.is_none() {
recipe.toolchain_image =
str_field(&v, "toolchainImage").or_else(|| str_field(&v, "toolchain_image"));
}
if recipe.toolchain_image_sha256.is_none() {
recipe.toolchain_image_sha256 = str_field(&v, "toolchainImageSha256")
.or_else(|| str_field(&v, "toolchain_image_sha256"));
}
if let Some(r) = recipe.toolchain_image.clone() {
if let Some((name, digest)) = r.split_once("@sha256:") {
recipe.toolchain_image = Some(name.to_string());
if recipe.toolchain_image_sha256.is_none() {
recipe.toolchain_image_sha256 = Some(digest.to_string());
}
}
}
}
/// `.dep-v0` — cargo-auditable's zlib-compressed JSON dependency tree. Returns
/// `"<name> <version>"` of the package marked `root`.
///
/// The whole tree is deliberately NOT a column; this is the entry point, and the
/// bytes themselves are already in the archive.
fn read_dep_v0_root(section: &[u8]) -> Option<String> {
// cargo-auditable writes a ZLIB stream (RFC 1950): a two-byte CMF/FLG header
// around a raw DEFLATE body. `linflate` inflates raw DEFLATE, so the header
// is stepped over when it is there — recognised by the spec's own two rules
// (CM == 8, and the big-endian header word divisible by 31), never by
// assuming the first two bytes are always a header.
let compressed = match section {
[cmf, flg, rest @ ..]
if cmf & 0x0f == 8 && (u16::from(*cmf) << 8 | u16::from(*flg)) % 31 == 0 =>
{
rest
}
other => other,
};
// The inflate is bounded by what DEFLATE can physically produce from this
// many bytes, capped at [`DEP_V0_MAX`]: a section claiming to expand without
// limit is a bomb, and P-4 says a bomb yields a null column, not an OOM.
let hint = compressed.len().saturating_mul(1032).min(DEP_V0_MAX);
let raw = linflate::inflate_to_vec(compressed, hint).ok()?;
let v: serde_json::Value = serde_json::from_slice(&raw).ok()?;
let packages = v.get("packages")?.as_array()?;
let root = packages
.iter()
.find(|p| p.get("root").and_then(serde_json::Value::as_bool).unwrap_or(false))?;
let name = root.get("name")?.as_str()?;
let version = root.get("version")?.as_str()?;
Some(format!("{name} {version}"))
}
fn str_field(v: &serde_json::Value, key: &str) -> Option<String> {
v.get(key)?.as_str().map(str::to_owned)
}
/// The ISA level a binary DEMANDS, from `.note.gnu.property`.
///
/// x86: `GNU_PROPERTY_X86_ISA_1_NEEDED` (0xc0008002) — bit 0 baseline, 1 = v2,
/// 2 = v3, 3 = v4; the highest bit set is the level.
/// aarch64: `GNU_PROPERTY_AARCH64_FEATURE_1_AND` (0xc0000000) — BTI / PAC.
fn abi_level_from_property(desc: &[u8], endian: Endianness, class64: bool) -> Option<String> {
let align = if class64 { 8 } else { 4 };
let mut off = 0usize;
while off + 8 <= desc.len() {
let ptype = u32_at(desc, off, endian)?;
let psize = u32_at(desc, off + 4, endian)? as usize;
let pdata = desc.get(off + 8..off + 8 + psize)?;
match ptype {
0xc000_8002 => {
let bits = u32_at(pdata, 0, endian)?;
let level = match 32 - bits.leading_zeros() {
0 => return None,
1 => "x86-64-baseline",
2 => "x86-64-v2",
3 => "x86-64-v3",
_ => "x86-64-v4",
};
return Some(level.to_string());
}
0xc000_0000 => {
let bits = u32_at(pdata, 0, endian)?;
let mut f: Vec<&str> = Vec::new();
if bits & 1 != 0 {
f.push("bti");
}
if bits & 2 != 0 {
f.push("pac");
}
if !f.is_empty() {
return Some(f.join("+"));
}
}
_ => {}
}
// Each property is padded to the file's pointer alignment.
let step = 8 + psize.next_multiple_of(align);
off = off.checked_add(step)?;
}
None
}
fn u32_at(b: &[u8], off: usize, endian: Endianness) -> Option<u32> {
let s: [u8; 4] = b.get(off..off + 4)?.try_into().ok()?;
Some(match endian {
Endianness::Little => u32::from_le_bytes(s),
Endianness::Big => u32::from_be_bytes(s),
})
}
/// What this ELF *is* — the distinction `e_type` cannot draw, because a PIE
/// executable and a shared library are both `ET_DYN`. The separator is
/// `PT_INTERP`: an executable names its interpreter, a library does not.
fn member_kind(etype: &str, has_interp: bool, has_modinfo: bool, has_debug: bool) -> &'static str {
match etype {
"exec" => "executable",
"dyn" if has_interp => "pie-executable",
"dyn" => "shared-object",
"rel" if has_modinfo => "kernel-module",
"rel" if has_debug => "debuginfo",
"rel" => "relocatable",
"core" => "core",
_ => "relocatable",
}
}
/// The canonical machine vocabulary. `crate::claim` folds path spellings into
/// exactly these strings so `arch_lies` compares like with like.
pub fn machine_name(m: elf::Machine) -> &'static str {
match m {
elf::EM_386 => "x86",
elf::EM_X86_64 => "x86-64",
elf::EM_AARCH64 => "aarch64",
elf::EM_ARM => "arm",
elf::EM_RISCV => "riscv64",
elf::EM_PPC64 => "powerpc64",
elf::EM_S390 => "s390x",
elf::EM_LOONGARCH => "loongarch64",
elf::EM_MIPS => "mips64",
elf::EM_SPARCV9 => "sparc64",
elf::EM_NONE => "none",
_ => "unknown",
}
}
fn type_name(t: elf::FileType) -> &'static str {
match t {
elf::ET_REL => "rel",
elf::ET_EXEC => "exec",
elf::ET_DYN => "dyn",
elf::ET_CORE => "core",
_ => "unknown",
}
}
fn osabi_name(b: elf::OsAbi) -> String {
match b {
elf::ELFOSABI_NONE => "SysV",
elf::ELFOSABI_HPUX => "HP-UX",
elf::ELFOSABI_NETBSD => "NetBSD",
elf::ELFOSABI_GNU => "GNU",
elf::ELFOSABI_SOLARIS => "Solaris",
elf::ELFOSABI_AIX => "AIX",
elf::ELFOSABI_FREEBSD => "FreeBSD",
elf::ELFOSABI_OPENBSD => "OpenBSD",
elf::ELFOSABI_ARM => "ARM",
elf::ELFOSABI_STANDALONE => "Standalone",
_ => "unknown",
}
.to_string()
}
/// A NUL-terminated string out of raw section/segment bytes.
fn cstr(b: &[u8]) -> Option<String> {
let end = b.iter().position(|c| *c == 0).unwrap_or(b.len());
if end == 0 {
return None;
}
Some(String::from_utf8_lossy(&b[..end]).into_owned())
}
fn hex(b: &[u8]) -> String {
let mut s = String::with_capacity(b.len() * 2);
for x in b {
s.push(char::from_digit(u32::from(x >> 4), 16).unwrap_or('0'));
s.push(char::from_digit(u32::from(x & 0xf), 16).unwrap_or('0'));
}
s
}