Skip to main content

anodizer_core/
libc_check.rs

1//! glibc-ceiling check.
2//!
3//! A binary built against a newer glibc than the oldest distro you support
4//! will fail to run there with a cryptic `version 'GLIBC_2.xx' not found`.
5//! This check reads the required glibc symbol versions from a `.deb`'s
6//! embedded ELF binary (the `.gnu.version_r` / verneed records, surfaced by
7//! the `object` crate) and fails if the maximum required version exceeds a
8//! configured floor.
9//!
10//! musl-linked binaries have NO glibc requirement (no verneed entries naming
11//! `GLIBC_*`) and are SKIPPED — which is precisely the point: a musl build
12//! hides a glibc-floor regression, so "no glibc requirement" is a pass, not a
13//! defect.
14//!
15//! Two layers, both unit-testable without a network or Docker:
16//! - [`GlibcVersion`] — numeric, component-wise version parse + compare
17//!   (tested on synthetic strings; `2.36` vs `2.4` vs `2.36.1` must order
18//!   numerically, NOT lexically).
19//! - [`max_glibc_requirement`] — extracts the maximum `GLIBC_*` requirement
20//!   from ELF bytes via `object` (tested on a real binary / fixture).
21
22use std::cmp::Ordering;
23
24use anyhow::Result;
25use object::elf::{FileHeader32, FileHeader64};
26use object::read::elf::FileHeader;
27use object::{Endianness, FileKind, SymbolIndex};
28
29/// A dotted glibc version (e.g. `GLIBC_2.36` → `[2, 36]`), compared
30/// component-wise as integers so `2.36 > 2.4` (lexical string compare would
31/// wrongly order `2.4 > 2.36`).
32#[derive(Debug, Clone)]
33pub struct GlibcVersion {
34    components: Vec<u64>,
35    /// The original dotted text, retained for diagnostics.
36    raw: String,
37}
38
39// Equality is defined by the numeric ordering (trailing-zero-insensitive), so
40// `2.36 == 2.36.0`. Deriving `PartialEq` would instead compare the component
41// vecs field-wise and the `raw` string, making those two unequal — which would
42// contradict `Ord`. Keep the two consistent.
43impl PartialEq for GlibcVersion {
44    fn eq(&self, other: &Self) -> bool {
45        self.cmp(other) == Ordering::Equal
46    }
47}
48
49impl Eq for GlibcVersion {}
50
51impl GlibcVersion {
52    /// Parse a dotted version body (the part AFTER the `GLIBC_` prefix, e.g.
53    /// `2.36` or `2.2.5`). Returns `None` when a component is non-numeric.
54    pub fn parse(body: &str) -> Option<Self> {
55        let mut components = Vec::new();
56        for part in body.split('.') {
57            components.push(part.parse::<u64>().ok()?);
58        }
59        if components.is_empty() {
60            return None;
61        }
62        Some(Self {
63            components,
64            raw: body.to_string(),
65        })
66    }
67
68    /// The original dotted text (without the `GLIBC_` prefix).
69    pub fn raw(&self) -> &str {
70        &self.raw
71    }
72}
73
74impl PartialOrd for GlibcVersion {
75    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
76        Some(self.cmp(other))
77    }
78}
79
80impl Ord for GlibcVersion {
81    fn cmp(&self, other: &Self) -> Ordering {
82        // Component-wise numeric compare; a missing trailing component counts
83        // as 0 so `2.36` == `2.36.0` and `2.36.1 > 2.36`.
84        let len = self.components.len().max(other.components.len());
85        for i in 0..len {
86            let a = self.components.get(i).copied().unwrap_or(0);
87            let b = other.components.get(i).copied().unwrap_or(0);
88            match a.cmp(&b) {
89                Ordering::Equal => continue,
90                non_eq => return non_eq,
91            }
92        }
93        Ordering::Equal
94    }
95}
96
97/// Outcome of checking one `.deb`'s embedded binary against a glibc ceiling.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum LibcCheckOutcome {
100    /// No `GLIBC_*` requirement found — a static or musl binary. Skipped (a
101    /// pass): musl hides the very floor regression this check guards.
102    NoGlibcRequirement,
103    /// The maximum required glibc version is within the ceiling.
104    WithinCeiling {
105        /// The highest `GLIBC_*` version the binary requires.
106        max: String,
107    },
108    /// The maximum required glibc version EXCEEDS the ceiling — a defect.
109    ExceedsCeiling {
110        /// The highest `GLIBC_*` version the binary requires.
111        max: String,
112        /// The configured ceiling it exceeds.
113        ceiling: String,
114    },
115}
116
117/// Extract the maximum `GLIBC_*` symbol-version requirement from an ELF's
118/// `.gnu.version_r` data, regardless of class (32- or 64-bit) or byte order
119/// (little- or big-endian).
120///
121/// Both 32-bit (i686 / armv7) and 64-bit are supported release targets, so the
122/// parse dispatches on [`object::FileKind`] and runs the same symbol-version
123/// scan over whichever ELF header class the bytes carry. Endianness is read
124/// from the header at runtime.
125///
126/// Returns `Ok(None)` when the bytes have no versioned-symbol table, no
127/// `GLIBC_*` requirement (static / musl), OR are not a parseable ELF at all
128/// (a non-ELF or unsupported object DEGRADES TO SKIP rather than failing): the
129/// check exists only to flag a real glibc-floor violation, so an
130/// uninspectable artifact is a pass, not a defect. Returns `Ok(Some(max))`
131/// with the numerically-greatest requirement otherwise.
132pub fn max_glibc_requirement(elf_bytes: &[u8]) -> Result<Option<GlibcVersion>> {
133    match FileKind::parse(elf_bytes) {
134        Ok(FileKind::Elf32) => {
135            match FileHeader32::<Endianness>::parse(elf_bytes) {
136                Ok(elf) => scan_glibc_requirement(elf, elf_bytes),
137                // A header that fails the deeper parse is uninspectable, not a
138                // glibc violation: degrade to SKIP.
139                Err(_) => Ok(None),
140            }
141        }
142        Ok(FileKind::Elf64) => match FileHeader64::<Endianness>::parse(elf_bytes) {
143            Ok(elf) => scan_glibc_requirement(elf, elf_bytes),
144            Err(_) => Ok(None),
145        },
146        // Not an ELF (or an uninspected object kind): skip, don't flag.
147        _ => Ok(None),
148    }
149}
150
151/// Scan one parsed ELF header's versioned dynamic symbols for the
152/// numerically-greatest `GLIBC_*` requirement.
153///
154/// Generic over the ELF header class so the same logic serves 32- and 64-bit,
155/// little- and big-endian binaries. Returns `Ok(None)` when the ELF has no
156/// `.gnu.version` table or no `GLIBC_*` entry (static / musl).
157fn scan_glibc_requirement<Elf: FileHeader<Endian = Endianness>>(
158    elf: &Elf,
159    elf_bytes: &[u8],
160) -> Result<Option<GlibcVersion>> {
161    // A malformed section/symbol table on an otherwise-valid ELF header is
162    // uninspectable, not a glibc violation: each step degrades to SKIP
163    // (`Ok(None)`) rather than surfacing an error the caller would log as a
164    // false post-release issue.
165    let Ok(endian) = elf.endian() else {
166        return Ok(None);
167    };
168    let Ok(sections) = elf.sections(endian, elf_bytes) else {
169        return Ok(None);
170    };
171    let Ok(versions) = sections.versions(endian, elf_bytes) else {
172        return Ok(None);
173    };
174    let Some(version_table) = versions else {
175        // No `.gnu.version` table at all — static / musl binary.
176        return Ok(None);
177    };
178    let Ok(symbols) = sections.symbols(endian, elf_bytes, object::elf::SHT_DYNSYM) else {
179        return Ok(None);
180    };
181
182    let mut max: Option<GlibcVersion> = None;
183    for index in 0..symbols.len() {
184        let vindex = version_table.version_index(endian, SymbolIndex(index));
185        let Ok(Some(version)) = version_table.version(vindex) else {
186            continue;
187        };
188        let Ok(name) = std::str::from_utf8(version.name()) else {
189            continue;
190        };
191        let Some(body) = name.strip_prefix("GLIBC_") else {
192            continue;
193        };
194        let Some(parsed) = GlibcVersion::parse(body) else {
195            continue;
196        };
197        if max.as_ref().is_none_or(|cur| parsed > *cur) {
198            max = Some(parsed);
199        }
200    }
201    Ok(max)
202}
203
204/// Check one ELF binary's glibc requirement against `ceiling`.
205///
206/// `ceiling` is the dotted glibc floor (e.g. `"2.36"`). A binary requiring a
207/// glibc strictly NEWER than `ceiling` produces [`LibcCheckOutcome::ExceedsCeiling`].
208pub fn check_glibc_ceiling(elf_bytes: &[u8], ceiling: &str) -> Result<LibcCheckOutcome> {
209    let ceiling_ver = GlibcVersion::parse(ceiling).ok_or_else(|| {
210        anyhow::anyhow!("verify-release: invalid glibc_ceiling '{ceiling}' (expected e.g. 2.36)")
211    })?;
212    match max_glibc_requirement(elf_bytes)? {
213        None => Ok(LibcCheckOutcome::NoGlibcRequirement),
214        Some(max) if max > ceiling_ver => Ok(LibcCheckOutcome::ExceedsCeiling {
215            max: max.raw().to_string(),
216            ceiling: ceiling.to_string(),
217        }),
218        Some(max) => Ok(LibcCheckOutcome::WithinCeiling {
219            max: max.raw().to_string(),
220        }),
221    }
222}
223
224/// Compare a set of `GLIBC_*` requirement strings against a ceiling, without
225/// touching ELF bytes — the synthetic-symbol-list path used in tests and by
226/// any caller that has already extracted requirement names.
227pub fn check_glibc_requirements(requirements: &[&str], ceiling: &str) -> Result<LibcCheckOutcome> {
228    let ceiling_ver = GlibcVersion::parse(ceiling).ok_or_else(|| {
229        anyhow::anyhow!("verify-release: invalid glibc_ceiling '{ceiling}' (expected e.g. 2.36)")
230    })?;
231    let max = requirements
232        .iter()
233        .filter_map(|r| r.strip_prefix("GLIBC_"))
234        .filter_map(GlibcVersion::parse)
235        .max();
236    match max {
237        None => Ok(LibcCheckOutcome::NoGlibcRequirement),
238        Some(max) if max > ceiling_ver => Ok(LibcCheckOutcome::ExceedsCeiling {
239            max: max.raw().to_string(),
240            ceiling: ceiling.to_string(),
241        }),
242        Some(max) => Ok(LibcCheckOutcome::WithinCeiling {
243            max: max.raw().to_string(),
244        }),
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn numeric_compare_beats_lexical() {
254        // Lexically "2.4" > "2.36" (because '4' > '3'); numerically it's the
255        // reverse. This is the canonical bug the check must not have.
256        let v2_4 = GlibcVersion::parse("2.4").unwrap();
257        let v2_36 = GlibcVersion::parse("2.36").unwrap();
258        assert!(v2_36 > v2_4, "2.36 must be greater than 2.4 numerically");
259    }
260
261    #[test]
262    fn patch_component_ordering() {
263        let v2_36 = GlibcVersion::parse("2.36").unwrap();
264        let v2_36_1 = GlibcVersion::parse("2.36.1").unwrap();
265        assert!(v2_36_1 > v2_36, "2.36.1 > 2.36");
266        // Trailing zero equals the shorter form.
267        assert_eq!(GlibcVersion::parse("2.36.0").unwrap(), v2_36);
268    }
269
270    #[test]
271    fn rejects_non_numeric() {
272        assert!(GlibcVersion::parse("2.x").is_none());
273        assert!(GlibcVersion::parse("").is_none());
274    }
275
276    #[test]
277    fn requirement_exceeds_ceiling_fails() {
278        let out = check_glibc_requirements(&["GLIBC_2.2.5", "GLIBC_2.38"], "2.36").unwrap();
279        assert_eq!(
280            out,
281            LibcCheckOutcome::ExceedsCeiling {
282                max: "2.38".to_string(),
283                ceiling: "2.36".to_string(),
284            }
285        );
286    }
287
288    #[test]
289    fn requirement_within_ceiling_passes() {
290        let out = check_glibc_requirements(&["GLIBC_2.2.5", "GLIBC_2.31"], "2.36").unwrap();
291        assert_eq!(
292            out,
293            LibcCheckOutcome::WithinCeiling {
294                max: "2.31".to_string()
295            }
296        );
297    }
298
299    #[test]
300    fn requirement_equal_to_ceiling_passes() {
301        // Equal is within (the ceiling is the floor you support, inclusive).
302        let out = check_glibc_requirements(&["GLIBC_2.36"], "2.36").unwrap();
303        assert_eq!(
304            out,
305            LibcCheckOutcome::WithinCeiling {
306                max: "2.36".to_string()
307            }
308        );
309    }
310
311    #[test]
312    fn no_glibc_requirement_is_skipped() {
313        // A musl binary's verneed has no GLIBC_* entries.
314        let out = check_glibc_requirements(&["libc.so.6"], "2.36").unwrap();
315        assert_eq!(out, LibcCheckOutcome::NoGlibcRequirement);
316        let empty = check_glibc_requirements(&[], "2.36").unwrap();
317        assert_eq!(empty, LibcCheckOutcome::NoGlibcRequirement);
318    }
319
320    #[test]
321    fn lexical_max_would_be_wrong() {
322        // /bin/ls-style real requirement set: lexical max is "2.9", numeric
323        // max is "2.34". The check must use numeric.
324        let reqs = [
325            "GLIBC_2.29",
326            "GLIBC_2.2.5",
327            "GLIBC_2.34",
328            "GLIBC_2.4",
329            "GLIBC_2.9",
330        ];
331        let out = check_glibc_requirements(&reqs, "2.40").unwrap();
332        assert_eq!(
333            out,
334            LibcCheckOutcome::WithinCeiling {
335                max: "2.34".to_string()
336            },
337            "numeric max is 2.34, not the lexical 2.9"
338        );
339    }
340
341    #[test]
342    fn invalid_ceiling_errors() {
343        assert!(check_glibc_requirements(&["GLIBC_2.36"], "not-a-version").is_err());
344    }
345
346    /// Build a minimal but structurally-valid 32-bit little-endian ELF header
347    /// (`EI_CLASS = ELFCLASS32`, no section headers). It carries no
348    /// `.gnu.version` table, so the glibc scan must reach the
349    /// `NoGlibcRequirement` (skip) branch — NOT error. This is the i686/armv7
350    /// case the old 64-bit-only parse rejected with a false issue.
351    fn minimal_elf32_le() -> Vec<u8> {
352        // 52-byte ELF32 header. e_shoff/e_shnum left zero → no sections.
353        let mut h = vec![0u8; 52];
354        h[0..4].copy_from_slice(b"\x7fELF");
355        h[4] = 1; // EI_CLASS = ELFCLASS32
356        h[5] = 1; // EI_DATA  = ELFDATA2LSB (little-endian)
357        h[6] = 1; // EI_VERSION = EV_CURRENT
358        // e_type = ET_DYN (3), e_machine = EM_386 (3), e_version = 1.
359        h[16] = 3;
360        h[18] = 3;
361        h[20] = 1;
362        h
363    }
364
365    #[test]
366    fn elf32_parses_without_false_issue() {
367        // The 64-bit-only parse returned Err on this header (a 32-bit .deb ELF),
368        // which the stage pushed as a false post-release issue. Polymorphic
369        // dispatch must parse it and SKIP (no glibc table → NoGlibcRequirement),
370        // never error.
371        let bytes = minimal_elf32_le();
372        assert_eq!(FileKind::parse(bytes.as_slice()).unwrap(), FileKind::Elf32);
373        let max = max_glibc_requirement(&bytes)
374            .expect("32-bit ELF must parse, not error (false-issue regression)");
375        assert!(
376            max.is_none(),
377            "no .gnu.version table → no glibc requirement"
378        );
379        // And the ceiling check must report a skip, not an error/exceed.
380        let out = check_glibc_ceiling(&bytes, "2.36").unwrap();
381        assert_eq!(out, LibcCheckOutcome::NoGlibcRequirement);
382    }
383
384    /// Build a structurally-valid 32-bit little-endian ELF whose
385    /// `.gnu.version_r` declares a `GLIBC_2.99` requirement on one versioned
386    /// dynamic symbol, with a section-header table linking
387    /// `.gnu.version` → `.dynsym` → `.dynstr` and `.gnu.version_r` → `.dynstr`
388    /// exactly as a real linker emits. The `object` crate's real verneed walk
389    /// must extract `GLIBC_2.99` from this — proving 32-bit detection, not just
390    /// the 32-bit no-issue skip path.
391    ///
392    /// Layout (all offsets file-absolute, little-endian):
393    ///   [0]   ELF32 header (52 bytes)
394    ///   .dynstr   string table: "\0libc.so.6\0GLIBC_2.99\0glibc99\0"
395    ///   .dynsym   2 × Sym32 (16 bytes each): index 0 null, index 1 versioned
396    ///   .gnu.version  2 × Versym (u16): [0 (local), 2 (our version index)]
397    ///   .gnu.version_r  Verneed(16) + Vernaux(16) naming GLIBC_2.99, vna_other=2
398    ///   .shstrtab one NUL byte (section names are matched by sh_type, not name)
399    ///   section-header table: 6 × SectionHeader32 (40 bytes each)
400    fn elf32_le_with_glibc_2_99() -> Vec<u8> {
401        const SHT_STRTAB: u32 = 3;
402        const SHT_DYNSYM: u32 = 11;
403        const SHT_GNU_VERSYM: u32 = 0x6fff_ffff;
404        const SHT_GNU_VERNEED: u32 = 0x6fff_fffe;
405        // Version index assigned to the GLIBC_2.99 requirement. 0/1 are the
406        // reserved local/global indices, so the first real version is 2.
407        const VER_IDX: u16 = 2;
408
409        let le32 = |buf: &mut Vec<u8>, v: u32| buf.extend_from_slice(&v.to_le_bytes());
410        // .dynstr — index 0 must be the empty string.
411        let mut dynstr = vec![0u8];
412        let off_libc = dynstr.len() as u32;
413        dynstr.extend_from_slice(b"libc.so.6\0");
414        let off_glibc = dynstr.len() as u32;
415        dynstr.extend_from_slice(b"GLIBC_2.99\0");
416        let off_sym = dynstr.len() as u32;
417        dynstr.extend_from_slice(b"glibc99\0");
418
419        // .dynsym — Sym32 is 16 bytes: st_name(4) st_value(4) st_size(4)
420        // st_info(1) st_other(1) st_shndx(2). Index 0 is the reserved null
421        // entry; index 1 is our GLOBAL FUNC referencing the version.
422        let mut dynsym = Vec::new();
423        dynsym.extend_from_slice(&[0u8; 16]); // index 0: STN_UNDEF
424        le32(&mut dynsym, off_sym); // st_name
425        le32(&mut dynsym, 0); // st_value
426        le32(&mut dynsym, 0); // st_size
427        dynsym.push((1 << 4) | 2); // st_info: STB_GLOBAL, STT_FUNC
428        dynsym.push(0); // st_other
429        dynsym.extend_from_slice(&1u16.to_le_bytes()); // st_shndx (any defined)
430
431        // .gnu.version — one Versym (u16) per dynsym entry. Symbol 0 is local
432        // (index 0), symbol 1 carries our version index.
433        let mut versym = Vec::new();
434        versym.extend_from_slice(&0u16.to_le_bytes());
435        versym.extend_from_slice(&VER_IDX.to_le_bytes());
436
437        // .gnu.version_r — Verneed(16) { vn_version, vn_cnt, vn_file, vn_aux,
438        // vn_next } then Vernaux(16) { vna_hash, vna_flags, vna_other,
439        // vna_name, vna_next }. vn_aux/vna offsets are relative to the verneed
440        // entry start (offset 0 here, single entry).
441        let mut verneed = Vec::new();
442        verneed.extend_from_slice(&1u16.to_le_bytes()); // vn_version = VER_NEED_CURRENT
443        verneed.extend_from_slice(&1u16.to_le_bytes()); // vn_cnt = 1 aux
444        le32(&mut verneed, off_libc); // vn_file → "libc.so.6"
445        le32(&mut verneed, 16); // vn_aux → Vernaux at +16
446        le32(&mut verneed, 0); // vn_next = 0 (last)
447        le32(&mut verneed, 0); // vna_hash (unused by the scan)
448        verneed.extend_from_slice(&0u16.to_le_bytes()); // vna_flags
449        verneed.extend_from_slice(&VER_IDX.to_le_bytes()); // vna_other = version index
450        le32(&mut verneed, off_glibc); // vna_name → "GLIBC_2.99"
451        le32(&mut verneed, 0); // vna_next = 0 (last)
452
453        let shstrtab = vec![0u8];
454
455        // Concatenate section bodies after the 52-byte header, tracking each
456        // body's file offset for its section header.
457        let mut img = vec![0u8; 52];
458        let place = |img: &mut Vec<u8>, body: &[u8]| -> (u32, u32) {
459            let off = img.len() as u32;
460            img.extend_from_slice(body);
461            (off, body.len() as u32)
462        };
463        let (dynstr_off, dynstr_sz) = place(&mut img, &dynstr);
464        let (dynsym_off, dynsym_sz) = place(&mut img, &dynsym);
465        let (versym_off, versym_sz) = place(&mut img, &versym);
466        let (verneed_off, verneed_sz) = place(&mut img, &verneed);
467        let (shstr_off, shstr_sz) = place(&mut img, &shstrtab);
468
469        // Section header table (40 bytes per SectionHeader32). Section name
470        // offsets all point at the shstrtab's leading NUL — the scan finds
471        // sections by sh_type, never by name.
472        let shoff = img.len() as u32;
473        let sh = |img: &mut Vec<u8>,
474                  sh_type: u32,
475                  offset: u32,
476                  size: u32,
477                  link: u32,
478                  info: u32,
479                  entsize: u32| {
480            le32(img, 0); // sh_name
481            le32(img, sh_type);
482            le32(img, 0); // sh_flags
483            le32(img, 0); // sh_addr
484            le32(img, offset);
485            le32(img, size);
486            le32(img, link);
487            le32(img, info);
488            le32(img, 0); // sh_addralign
489            le32(img, entsize);
490        };
491        // 0: SHT_NULL (required first entry).
492        sh(&mut img, 0, 0, 0, 0, 0, 0);
493        // 1: .dynstr.
494        sh(&mut img, SHT_STRTAB, dynstr_off, dynstr_sz, 0, 0, 0);
495        // 2: .dynsym → links .dynstr (section 1); sh_info = first non-local.
496        sh(&mut img, SHT_DYNSYM, dynsym_off, dynsym_sz, 1, 1, 16);
497        // 3: .gnu.version → links .dynsym (section 2).
498        sh(&mut img, SHT_GNU_VERSYM, versym_off, versym_sz, 2, 0, 2);
499        // 4: .gnu.version_r → links .dynstr (section 1); sh_info = verneed cnt.
500        sh(&mut img, SHT_GNU_VERNEED, verneed_off, verneed_sz, 1, 1, 0);
501        // 5: .shstrtab (named by e_shstrndx below).
502        sh(&mut img, SHT_STRTAB, shstr_off, shstr_sz, 0, 0, 0);
503        let shnum: u16 = 6;
504        let shstrndx: u16 = 5;
505
506        // Fill the ELF32 header now that section-table geometry is known.
507        img[0..4].copy_from_slice(b"\x7fELF");
508        img[4] = 1; // EI_CLASS = ELFCLASS32
509        img[5] = 1; // EI_DATA  = ELFDATA2LSB
510        img[6] = 1; // EI_VERSION
511        img[16..18].copy_from_slice(&3u16.to_le_bytes()); // e_type = ET_DYN
512        img[18..20].copy_from_slice(&3u16.to_le_bytes()); // e_machine = EM_386
513        img[20..24].copy_from_slice(&1u32.to_le_bytes()); // e_version
514        img[32..36].copy_from_slice(&shoff.to_le_bytes()); // e_shoff
515        img[40..42].copy_from_slice(&52u16.to_le_bytes()); // e_ehsize
516        img[46..48].copy_from_slice(&40u16.to_le_bytes()); // e_shentsize
517        img[48..50].copy_from_slice(&shnum.to_le_bytes()); // e_shnum
518        img[50..52].copy_from_slice(&shstrndx.to_le_bytes()); // e_shstrndx
519        img
520    }
521
522    #[test]
523    fn elf32_glibc_requirement_above_ceiling_is_detected() {
524        // A 32-bit (i686/armv7) .deb ELF requiring GLIBC_2.99 must be DETECTED
525        // as exceeding a 2.36 ceiling via the real `object` verneed walk — not
526        // skipped, not passed. This is the 32-bit DETECTION path; the sibling
527        // `elf32_parses_without_false_issue` only covers the 32-bit SKIP path.
528        let bytes = elf32_le_with_glibc_2_99();
529        assert_eq!(FileKind::parse(bytes.as_slice()).unwrap(), FileKind::Elf32);
530
531        // The extraction must surface the real requirement, proving the scan
532        // walked the Elf32 .gnu.version_r (a broken 32-bit path would return
533        // None → NoGlibcRequirement, failing the assert below).
534        let max = max_glibc_requirement(&bytes)
535            .expect("32-bit ELF must parse")
536            .expect("32-bit .gnu.version_r must yield GLIBC_2.99");
537        assert_eq!(max.raw(), "2.99");
538
539        let out = check_glibc_ceiling(&bytes, "2.36").unwrap();
540        assert_eq!(
541            out,
542            LibcCheckOutcome::ExceedsCeiling {
543                max: "2.99".to_string(),
544                ceiling: "2.36".to_string(),
545            },
546            "32-bit GLIBC_2.99 > 2.36 must be flagged, not skipped/passed"
547        );
548    }
549
550    #[test]
551    fn non_elf_degrades_to_skip() {
552        // A non-ELF or unparseable artifact must DEGRADE TO SKIP, not surface a
553        // libc-check error the stage would log as a false post-release issue.
554        let max = max_glibc_requirement(b"not an elf at all").unwrap();
555        assert!(max.is_none());
556        let out = check_glibc_ceiling(b"\x7fELFgarbage", "2.36").unwrap();
557        assert_eq!(out, LibcCheckOutcome::NoGlibcRequirement);
558    }
559
560    #[test]
561    fn real_elf_extraction_matches_numeric_max() {
562        // Parse a real glibc-linked binary from the host and assert the
563        // extracted max is a sane GLIBC version. This proves the `object`
564        // .gnu.version_r path, not just the synthetic compare. Skips
565        // gracefully when /bin/ls is absent or not a 64-bit LE ELF.
566        let Ok(bytes) = std::fs::read("/bin/ls") else {
567            eprintln!("skipping: /bin/ls not readable");
568            return;
569        };
570        match max_glibc_requirement(&bytes) {
571            Ok(Some(max)) => {
572                // /bin/ls links glibc; the max must parse and be >= 2.2.5.
573                let floor = GlibcVersion::parse("2.2.5").unwrap();
574                assert!(max >= floor, "extracted glibc max {} too low", max.raw());
575            }
576            Ok(None) => eprintln!("skipping: /bin/ls reported no glibc requirement"),
577            Err(e) => eprintln!("skipping: /bin/ls not a parseable ELF here: {e}"),
578        }
579    }
580}