crates/nornir-build-thing/src/code_digest.rs
//! **The bridge between the binary that was AUDITED and the binary that SHIPS.**
//!
//! `[profile.release] strip = true` means the delivered artifact has no symbol
//! table, so [`receipt_proof`](crate::receipt_proof) can only answer
//! `NotMeasured` for it — correctly, and forever. The SLSA answer is to audit an
//! unstripped binary at build time and let the *verdict* travel as text. That
//! answer has one hole, and this module is the plug:
//!
//! > how do you know the binary you audited is the program you shipped?
//!
//! Not by trusting a file name, and not by hoping two compilations agreed. By
//! measuring: **strip removes only non-allocated sections.** Everything the CPU
//! ever executes or reads — `.text`, `.rodata`, `.data`, `.data.rel.ro`,
//! `.nordisk.buildthing` — is allocated and is untouched. So the SHA-256 over
//! the allocated sections is the same number for the stripped artifact and for
//! the unstripped binary it came from, and it is a *different* number the moment
//! one byte of the program differs.
//!
//! # Measured on oden, 2026-08-06
//!
//! One crate, `opt-level = 3`, `lto = "thin"`, `codegen-units = 1`, built twice —
//! once with `strip = true` and once with `CARGO_PROFILE_RELEASE_STRIP=none`:
//!
//! ```text
//! whole-file sha256 DIFFER 339 488 B stripped, 2 254 768 B unstripped
//! .text sha256 IDENTICAL 2c144be73cd3da03…
//! .rodata sha256 IDENTICAL 9ffd5286993ace7b…
//! .data sha256 IDENTICAL ea71b316a465b8d1…
//! .data.rel.ro sha256 IDENTICAL 463630187cf26eff…
//! ```
//!
//! Two facts also measured that day, because both were assumed and neither held:
//!
//! * **`target/release/deps/<bin>` is not an unstripped copy.** Under
//! `strip = true` it is a *hardlink to the same inode* as `target/release/<bin>`
//! — same size, no `.symtab`. rustc strips at link time; there is no pre-strip
//! artifact on disk to audit. Obtaining one requires a build with strip off.
//! * **`objcopy --strip-all` is not byte-identical to rustc's strip.** 339 480 B
//! against 339 488 B, different whole-file digests, identical allocated
//! sections. So the two binaries can never be bridged by their file hashes, and
//! the section digest is not a convenience — it is the only bridge there is.
//!
//! # What this is not
//!
//! It is not a reproducible-build claim and it is not a signature. It says two
//! files carry the same program, and it says nothing about who made them. A
//! caller that needs "nobody tampered with this" needs a signature; a caller that
//! needs "the audit I am reading was performed on these instructions" needs this.
use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// ELF `SHF_ALLOC` — the section occupies memory during execution.
const SHF_ALLOC: u64 = 0x2;
/// **Allocated sections that are excluded anyway, and the one reason.**
///
/// `.note.gnu.build-id` is the linker's own hash **over the output file**, and
/// the output file includes the symbol table. So the one section in a stripped
/// binary that is guaranteed to differ from its unstripped twin is the section
/// whose entire job is to identify the file — and including it here would make
/// this digest a hash of a hash of the file, which is the thing it exists not to
/// be.
///
/// Measured on oden 2026-08-06, one fixture built twice, `strip` the only
/// difference. Every allocated section compared, one at a time:
///
/// ```text
/// .interp .note.gnu.property .note.ABI-tag .gnu.hash .dynsym .dynstr
/// .gnu.version .gnu.version_r .rela.dyn .rela.plt .eh_frame .eh_frame_hdr
/// .gcc_except_table .rodata* .init .fini .plt .plt.got .text .data.rel.ro
/// .dynamic .init_array .fini_array .got .got.plt .data .bss IDENTICAL
/// .note.gnu.build-id DIFFERS
/// ```
///
/// Excluded **by name** rather than by section type: `.note.ABI-tag` and
/// `.note.gnu.property` are notes too, they are real properties of the program
/// (the ABI floor, the CET/IBT markings), and they are stable. A linker that
/// spelled the build-id note differently would land back inside the digest and
/// produce a refusal, which is the safe direction to be wrong in — a false
/// `DifferentProgram` gets investigated, a false `same_program` does not.
pub const EXCLUDED_SECTIONS: &[&str] = &[".note.gnu.build-id"];
/// Mach-O `S_ATTR_DEBUG` — the one section attribute that means "not loaded".
const S_ATTR_DEBUG: u32 = 0x0200_0000;
/// COFF `IMAGE_SCN_MEM_DISCARDABLE` — the section is dropped before execution.
const IMAGE_SCN_MEM_DISCARDABLE: u32 = 0x0200_0000;
/// The SHA-256 over an artifact's loadable content, and what went into it.
///
/// `sections` and `bytes` are carried because a digest alone cannot be
/// sanity-checked. A digest computed over **zero** sections is a perfectly valid
/// SHA-256 of nothing, it is the same value for every artifact on earth, and it
/// would silently make [`same_program`] answer `true` for two unrelated files.
/// [`code_digest`] refuses that case outright; the counts are what let a reader
/// see it did.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodeDigest {
/// Lowercase-hex SHA-256 over every allocated section, name and contents.
pub sha256: String,
/// How many allocated sections went into it.
pub sections: usize,
/// How many bytes of section contents went into it.
pub bytes: u64,
}
impl CodeDigest {
/// Do these two digests describe the same program?
///
/// Compares the whole value, not just the hash: two digests that agree on a
/// hash while disagreeing on how many sections produced it is a bug in this
/// module, and folding it into `true` would hide it.
#[must_use]
pub fn same_program(&self, other: &Self) -> bool {
self == other
}
}
impl std::fmt::Display for CodeDigest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} ({} allocated sections, {} bytes)",
self.sha256, self.sections, self.bytes
)
}
}
/// Is this section part of the program the CPU runs, rather than metadata a
/// stripper is entitled to throw away?
///
/// Per format, because "allocated" is spelled differently in each and guessing
/// from the section name would put `.debug_str` and `.text` in the same bucket on
/// whichever format we guessed wrong:
///
/// ```text
/// ELF SHF_ALLOC in sh_flags .symtab/.strtab/.debug_* have it clear
/// Mach-O NOT S_ATTR_DEBUG __DWARF carries the attribute
/// COFF NOT IMAGE_SCN_MEM_DISCARDABLE
/// ```
///
/// A format whose flags this build of `object` does not expose yields
/// [`SectionFlags::None`], and the section is **excluded**. That is the
/// conservative direction: an excluded section can make two different programs
/// look alike, so [`code_digest`] refuses a digest over nothing at all rather
/// than returning one nobody can trust.
fn is_allocated(flags: object::SectionFlags) -> bool {
match flags {
object::SectionFlags::Elf { sh_flags, .. } => sh_flags.0 & SHF_ALLOC != 0,
object::SectionFlags::MachO { flags, .. } => flags.0 & S_ATTR_DEBUG == 0,
object::SectionFlags::Coff { characteristics } => {
characteristics.0 & IMAGE_SCN_MEM_DISCARDABLE == 0
}
_ => false,
}
}
/// **Hash what the artifact at `path` will actually execute.**
///
/// Never runs it: `object` parses the file, exactly as
/// [`read_section_named`](crate::receipt::read_section_named) and
/// [`demangled_symbols`](crate::linkage::demangled_symbols) do, so this works on
/// a binary for an architecture this host could not execute.
///
/// # What goes into the hash, and why in that order
///
/// Allocated sections only, **sorted by name**, and for each one:
///
/// ```text
/// u64 LE name length so a rename cannot be absorbed by the next field
/// bytes name
/// u64 LE sh_size .bss has no contents but its size is program state
/// u64 LE contents length distinct from sh_size for a NOBITS section
/// bytes contents
/// ```
///
/// Sorted rather than in file order because file order is a linker's business
/// and this digest must survive a relayout that changes nothing the program does.
/// Length-prefixed rather than concatenated because `ab` + `c` and `a` + `bc`
/// hash the same otherwise, and that is a collision anybody could construct.
///
/// The section **address is deliberately absent.** It is the same in practice
/// (measured above) but it is a placement decision, and a digest that changed
/// when a linker moved a section would fail the exact case it exists to allow.
///
/// [`EXCLUDED_SECTIONS`] comes out too, for the one reason documented there.
///
/// # Errors
///
/// When the file cannot be read, cannot be parsed as an object file, or has **no
/// allocated sections at all**. The last one is not pedantry: the digest of an
/// empty fold is a constant, it is equal for every input, and returning it would
/// turn [`CodeDigest::same_program`] into `true` for two unrelated artifacts —
/// the silent-green shape this crate exists to refuse.
pub fn code_digest(path: &Path) -> Result<CodeDigest> {
use object::{Object, ObjectSection};
let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
let file = object::File::parse(&*bytes)
.with_context(|| format!("parse {} as an object file", path.display()))?;
let mut allocated: Vec<(String, u64, Vec<u8>)> = Vec::new();
for section in file.sections() {
if !is_allocated(section.flags()) {
continue;
}
let name = section.name().unwrap_or("<unnamed>").to_string();
if EXCLUDED_SECTIONS.contains(&name.as_str()) {
continue;
}
let data = section.data().unwrap_or(&[]).to_vec();
allocated.push((name, section.size(), data));
}
if allocated.is_empty() {
anyhow::bail!(
"{} has no allocated sections — a code digest over nothing is the same value for \
every artifact, so it is refused rather than returned",
path.display()
);
}
allocated.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
let mut hasher = Sha256::new();
let mut content_bytes = 0u64;
for (name, size, data) in &allocated {
hasher.update((name.len() as u64).to_le_bytes());
hasher.update(name.as_bytes());
hasher.update(size.to_le_bytes());
hasher.update((data.len() as u64).to_le_bytes());
hasher.update(data);
content_bytes += data.len() as u64;
}
Ok(CodeDigest {
sha256: hasher.finalize().iter().map(|b| format!("{b:02x}")).collect::<String>(),
sections: allocated.len(),
bytes: content_bytes,
})
}
/// **Which allocated sections differ between two artifacts**, by name.
///
/// [`code_digest`] answers *whether* two files carry the same program; this
/// answers *where* they stopped. It exists because the first question is useless
/// on its own when the answer is "no": a refusal that says only "different"
/// leaves the reader with two 16 MB files and no next step, and the next step is
/// always the section list. The names are the whole diagnosis — `.text` means the
/// code changed, `.rodata` alone usually means a string did, and
/// `.nordisk.buildthing` means the two builds disagree about their own receipt.
///
/// A section present in one file and absent from the other is named too. Returns
/// an empty vector when the two carry the same program, so the result reads the
/// same way the question does.
///
/// # Errors
///
/// When either file cannot be read or parsed — the same conditions as
/// [`code_digest`], and for the same reason.
pub fn differing_sections(a: &Path, b: &Path) -> Result<Vec<String>> {
let left = allocated_map(a)?;
let right = allocated_map(b)?;
let mut names: Vec<String> = left
.keys()
.chain(right.keys())
.cloned()
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.filter(|n| left.get(n) != right.get(n))
.collect();
names.sort();
Ok(names)
}
/// Every allocated section of `path` as name → (declared size, contents hash).
///
/// Hashes rather than contents so comparing two 16 MB binaries does not hold
/// both of them in memory twice over.
fn allocated_map(path: &Path) -> Result<BTreeMap<String, (u64, String)>> {
use object::{Object, ObjectSection};
let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
let file = object::File::parse(&*bytes)
.with_context(|| format!("parse {} as an object file", path.display()))?;
let mut out = BTreeMap::new();
for section in file.sections() {
if !is_allocated(section.flags()) {
continue;
}
let name = section.name().unwrap_or("<unnamed>").to_string();
if EXCLUDED_SECTIONS.contains(&name.as_str()) {
continue;
}
let data = section.data().unwrap_or(&[]);
let mut h = Sha256::new();
h.update(data);
out.insert(name, (section.size(), h.finalize().iter().map(|b| format!("{b:02x}")).collect::<String>()));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// The digest of the running test binary is stable across two reads, and is
/// computed over a plausible number of sections.
///
/// The section count is asserted because a digest over one section would be
/// technically valid and useless — the failure this module's `bail!` exists
/// for, one step short of empty.
#[test]
fn a_real_binary_digests_over_its_allocated_sections_and_only_those() {
let me = std::env::current_exe().expect("test binary");
let a = code_digest(&me).expect("the test binary is an object file");
let b = code_digest(&me).expect("twice");
assert_eq!(a, b, "the digest is not a function of the bytes");
assert!(
a.sections >= 4,
"a linked executable has more than {} allocated sections — the flag test is \
excluding things it should keep: {a}",
a.sections
);
assert!(a.bytes > 0, "{a}");
// Red side: the same read over a file that is not an object file must
// fail rather than return a digest of nothing.
let dir = tempfile::tempdir().expect("scratch");
let not_elf = dir.path().join("hello.txt");
std::fs::write(¬_elf, b"not an object file").expect("write");
let err = format!("{:#}", code_digest(¬_elf).unwrap_err());
assert!(err.contains("as an object file"), "{err}");
let missing = dir.path().join("nope");
let err = format!("{:#}", code_digest(&missing).unwrap_err());
assert!(err.contains("read"), "{err}");
}
/// The allocation test says yes to a loaded section and no to a stripped one,
/// in every format spelling.
///
/// Both halves matter. A predicate answering `true` for everything would
/// fold `.symtab` into the digest and make the whole bridge break the moment
/// anything is stripped — which is the only case it is for.
#[test]
fn the_allocation_test_separates_loaded_sections_from_metadata() {
// ELF: .text is SHF_ALLOC | SHF_EXECINSTR; .symtab and .debug_info are 0.
assert!(is_allocated(object::SectionFlags::Elf {
sh_type: object::elf::SectionType::default(),
sh_flags: object::elf::SectionFlags(0x6)
}));
assert!(is_allocated(object::SectionFlags::Elf {
sh_type: object::elf::SectionType::default(),
sh_flags: object::elf::SectionFlags(0x2)
}));
assert!(!is_allocated(object::SectionFlags::Elf {
sh_type: object::elf::SectionType::default(),
sh_flags: object::elf::SectionFlags(0x0)
}));
assert!(
!is_allocated(object::SectionFlags::Elf {
sh_type: object::elf::SectionType::default(),
sh_flags: object::elf::SectionFlags(0x30)
}),
"SHF_MERGE|SHF_STRINGS without SHF_ALLOC is .debug_str, not program text"
);
// Mach-O: __DWARF sections carry S_ATTR_DEBUG.
assert!(is_allocated(object::SectionFlags::MachO {
flags: object::macho::SectionFlags(0),
reserved2: 0
}));
assert!(!is_allocated(object::SectionFlags::MachO {
flags: object::macho::SectionFlags(S_ATTR_DEBUG),
reserved2: 0
}));
// COFF: discardable is dropped before execution.
assert!(is_allocated(object::SectionFlags::Coff {
characteristics: object::pe::SectionFlags(0x4000_0000)
}));
assert!(!is_allocated(object::SectionFlags::Coff {
characteristics: object::pe::SectionFlags(IMAGE_SCN_MEM_DISCARDABLE)
}));
// A format whose flags are not exposed is excluded, not guessed at.
assert!(!is_allocated(object::SectionFlags::None));
}
/// The build-id note is excluded, and it is the ONLY thing excluded.
///
/// The size of that list is the size of the hole in the bridge: everything
/// on it is program content this digest does not cover. It is asserted here
/// so growing it is a deliberate act with a test to update, rather than a
/// line somebody adds to make a red go away.
#[test]
fn exactly_one_allocated_section_is_excluded_and_it_is_the_file_s_own_hash() {
assert_eq!(
EXCLUDED_SECTIONS,
&[".note.gnu.build-id"],
"every entry here is program content the code digest does NOT cover — adding one \
widens the gap between `same_program` and `is the same program`"
);
// …and it really is excluded from a real binary's fold: the running test
// binary has a build-id, and the digest does not move when it changes.
let me = std::env::current_exe().expect("test binary");
let with_note = code_digest(&me).expect("digest");
assert!(
with_note.sections > 0,
"the exclusion must not be what empties the fold: {with_note}"
);
}
/// `same_program` compares the whole value, so a hash collision faked by hand
/// still does not pass.
#[test]
fn same_program_is_not_satisfied_by_the_hash_alone() {
let a = CodeDigest {
sha256: "abc".into(),
sections: 12,
bytes: 4096,
};
let mut b = a.clone();
assert!(a.same_program(&b));
b.sections = 11;
assert!(
!a.same_program(&b),
"two digests that disagree on their own inputs must not be called the same program"
);
}
}