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}