//! The glibc floor of a binary, read out of its ELF version requirements. //! //! A dynamically linked binary records which symbol versions it needs in //! `.gnu.version_r`, as a list of `GLIBC_x.y` names per shared object. The //! highest of those is the oldest glibc that can load it, and comparing it //! against a node's glibc answers "could this ever start there" before anything //! is built or moved. //! //! ## This is the weaker check, deliberately, and it runs first //! //! [`crate::deploy`]'s `ldd_guard_script` already asks the stronger question on //! the node: it runs that machine's own loader against the actual bytes, which //! covers every shared library and every symbol version rather than glibc //! alone, and answers "will this exec here" instead of "is this number smaller //! than that one". Nothing here replaces it and nothing here weakens it. //! //! What this buys is *when*. The loader check happens on the node, after the //! rsync, one step before the symlink swap. By then the bytes are built and //! moved. A number comparison can happen before either, so the class of failure //! that is knowable from two declared numbers is refused at the start of a //! promote rather than most of the way through it. //! //! ## Reading the section rather than scanning for strings //! //! `GLIBC_2.39` appears in `.dynstr` as a plain string, so a byte scan of the //! whole file finds it and is four lines long. It also finds any such string //! that is merely *data* — a version this binary embeds for some other reason, //! anything in a bundled asset — and a false positive here refuses a promote //! that would have worked. Parsing the section that actually states the //! requirement costs a hundred lines and cannot be fooled that way. //! //! ## What it does not read //! //! ELF64 little-endian only, which is the whole fleet (x86_64 and aarch64). //! Anything else, and anything that is not an ELF at all, returns `None`: //! "cannot verify" is not "known bad", the same call `arch_guard_script` makes //! for an unmapped architecture. A static binary has no `.gnu.version_r` and //! also returns `None`, which is correct rather than a gap: it needs no glibc. //! //! use std::cmp::Ordering; use std::fmt; /// A glibc version as two numbers, so `2.9` sorts below `2.39` rather than /// above it the way the strings do. /// /// That is not a hypothetical: string comparison puts `2.4` above `2.39`, and /// glibc's version names are exactly the shape where it goes wrong. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct GlibcVersion { major: u32, minor: u32, } impl GlibcVersion { /// Parse `2.39`, or a whole `GLIBC_2.39` version name. /// /// Rejects anything else, including the other version names that share the /// section (`GCC_3.0`, `GLIBCXX_3.4`), because only glibc's are being /// compared against a node's `ldd --version`. pub fn parse(s: &str) -> Option { let s = s.strip_prefix("GLIBC_").unwrap_or(s); let (major, minor) = s.split_once('.')?; // A trailing third component (`2.39.1`) is not a shape glibc uses in a // version name; refuse rather than silently reading the first two. if minor.contains('.') { return None; } Some(Self { major: major.parse().ok()?, minor: minor.parse().ok()?, }) } } impl Ord for GlibcVersion { fn cmp(&self, other: &Self) -> Ordering { (self.major, self.minor).cmp(&(other.major, other.minor)) } } impl PartialOrd for GlibcVersion { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl fmt::Display for GlibcVersion { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}.{}", self.major, self.minor) } } const SHT_GNU_VERNEED: u32 = 0x6fff_fffe; const SHDR_LEN: usize = 64; /// The highest `GLIBC_` version this ELF requires, or `None` when there is /// nothing to read: not an ELF, not ELF64 little-endian, statically linked, or /// carrying no glibc version requirements. /// /// Never errors. Every unreadable shape is a `None` rather than a failure, /// because the caller's question is "do I know this cannot run there" and an /// unparseable file is not evidence that it cannot. pub fn glibc_floor(bytes: &[u8]) -> Option { // e_ident: magic, then EI_CLASS (2 = ELF64) and EI_DATA (1 = little-endian). if bytes.len() < SHDR_LEN || &bytes[..4] != b"\x7fELF" || bytes[4] != 2 || bytes[5] != 1 { return None; } let e_shoff = u64_at(bytes, 0x28)? as usize; let e_shentsize = u16_at(bytes, 0x3A)? as usize; let e_shnum = u16_at(bytes, 0x3C)? as usize; // A section header table whose entries are not the size this parser knows is // not one to walk with a fixed stride. if e_shentsize != SHDR_LEN || e_shoff == 0 || e_shnum == 0 { return None; } let shdr = |i: usize| -> Option<&[u8]> { let start = e_shoff.checked_add(i.checked_mul(SHDR_LEN)?)?; bytes.get(start..start.checked_add(SHDR_LEN)?) }; let mut best: Option = None; for i in 0..e_shnum { let sh = shdr(i)?; if u32_at(sh, 4)? != SHT_GNU_VERNEED { continue; } let vn_off = u64_at(sh, 24)? as usize; let vn_size = u64_at(sh, 32)? as usize; // sh_link names the string table the version names are offsets into. let strtab = shdr(u32_at(sh, 40)? as usize)?; let str_off = u64_at(strtab, 24)? as usize; let str_size = u64_at(strtab, 32)? as usize; let strs = bytes.get(str_off..str_off.checked_add(str_size)?)?; let verneed = bytes.get(vn_off..vn_off.checked_add(vn_size)?)?; for name in verneed_names(verneed, strs) { if let Some(v) = GlibcVersion::parse(&name) { best = Some(best.map_or(v, |b: GlibcVersion| b.max(v))); } } } best } /// Walk the `Verneed` chain and its `Vernaux` entries, yielding every version /// name referenced. /// /// Both chains are `next`-offset linked lists that a malformed (or hostile) /// file could point in a circle, so both are bounded by the number of entries /// the headers claim and refuse a zero `next`, which is the shape a loop takes. fn verneed_names(verneed: &[u8], strs: &[u8]) -> Vec { const VERNEED_LEN: usize = 16; const VERNAUX_LEN: usize = 16; let mut names = Vec::new(); let mut vn = 0usize; // One pass per entry at most; the list cannot be longer than the section. for _ in 0..=(verneed.len() / VERNEED_LEN) { let Some(entry) = vn .checked_add(VERNEED_LEN) .and_then(|end| verneed.get(vn..end)) else { break; }; let Some(vn_cnt) = u16_at(entry, 2) else { break; }; let Some(vn_aux) = u32_at(entry, 8) else { break; }; let Some(vn_next) = u32_at(entry, 12) else { break; }; let Some(mut aux) = vn.checked_add(vn_aux as usize) else { break; }; for _ in 0..vn_cnt { let Some(a) = aux .checked_add(VERNAUX_LEN) .and_then(|end| verneed.get(aux..end)) else { break; }; let Some(vna_name) = u32_at(a, 8) else { break }; if let Some(name) = cstr_at(strs, vna_name as usize) { names.push(name); } let Some(vna_next) = u32_at(a, 12) else { break }; if vna_next == 0 { break; } let Some(next) = aux.checked_add(vna_next as usize) else { break; }; aux = next; } if vn_next == 0 { break; } let Some(next) = vn.checked_add(vn_next as usize) else { break; }; vn = next; } names } fn cstr_at(strs: &[u8], off: usize) -> Option { let rest = strs.get(off..)?; let end = rest.iter().position(|&b| b == 0)?; Some(String::from_utf8_lossy(&rest[..end]).into_owned()) } fn u16_at(b: &[u8], off: usize) -> Option { Some(u16::from_le_bytes(b.get(off..off + 2)?.try_into().ok()?)) } fn u32_at(b: &[u8], off: usize) -> Option { Some(u32::from_le_bytes(b.get(off..off + 4)?.try_into().ok()?)) } fn u64_at(b: &[u8], off: usize) -> Option { Some(u64::from_le_bytes(b.get(off..off + 8)?.try_into().ok()?)) } #[cfg(test)] mod tests { use super::*; #[test] fn versions_order_numerically_not_lexically() { let v = |s: &str| GlibcVersion::parse(s).expect("must parse"); // The whole reason this is two numbers: as strings, "2.4" > "2.39". assert!(v("2.39") > v("2.4")); assert!(v("2.39") > v("2.9")); assert!(v("2.42") > v("2.39")); assert_eq!(v("GLIBC_2.39"), v("2.39")); assert_eq!(v("2.39").to_string(), "2.39"); } #[test] fn only_glibc_version_names_parse() { // These share `.gnu.version_r` with glibc's and must not be compared // against a node's glibc. for other in [ "GCC_3.0", "GLIBCXX_3.4", "CXXABI_1.3", "", "GLIBC_", "2.39.1", ] { assert!( GlibcVersion::parse(other).is_none(), "`{other}` must not read as a glibc version" ); } } #[test] fn a_non_elf_reads_as_unknown_rather_than_failing() { assert_eq!(glibc_floor(b"not an elf at all"), None); assert_eq!(glibc_floor(&[]), None); // ELF32, and ELF64 big-endian: both real shapes this parser declines. let mut elf32 = vec![0u8; 128]; elf32[..4].copy_from_slice(b"\x7fELF"); elf32[4] = 1; elf32[5] = 1; assert_eq!(glibc_floor(&elf32), None); elf32[4] = 2; elf32[5] = 2; assert_eq!(glibc_floor(&elf32), None); } /// A truncated or malformed ELF must return `None` rather than panic. This /// walks every prefix of a real binary, which is the cheap way to cover the /// bounds checks in one test. #[test] fn truncation_at_any_length_is_unknown_rather_than_a_panic() { let Some(real) = a_real_binary() else { return; }; for cut in [0, 1, 4, 16, 63, 64, 65, 1024, real.len() / 2] { let _ = glibc_floor(&real[..cut.min(real.len())]); } } /// The real thing: this test binary is an ELF built on this host, so it must /// report a floor, and that floor must be one this machine can satisfy. /// /// Skipped rather than failed where there is no readable binary to point at, /// so the suite still runs somewhere this does not apply. #[test] fn a_real_binary_reports_a_plausible_floor() { let Some(bytes) = a_real_binary() else { return; }; let Some(floor) = glibc_floor(&bytes) else { // A fully static test binary is legitimate and has no floor. return; }; assert!( floor > GlibcVersion::parse("2.0").expect("must parse"), "a real binary's floor should be a real version, got {floor}" ); assert!( floor < GlibcVersion::parse("9.0").expect("must parse"), "a real binary's floor should not be from the future, got {floor}" ); } fn a_real_binary() -> Option> { std::fs::read(std::env::current_exe().ok()?).ok() } }