Skip to main content

max / makenotwork

Refuse a bundle above the node's declared glibc, before the rsync The early, weak half of a pair. ldd_guard_script already runs the node's own loader against the actual bytes one step before the symlink swap, which covers every shared library and every symbol version rather than glibc alone. Nothing here replaces it and a bundle that passes this can still fail that. What this adds is when. The loader check happens after the bundle is built and rsynced; two declared numbers can be compared before either, so the subset of failures already provable from them is refused at the start of a promote. The zero-margin state on production makes that subset live: three of its five binaries sit exactly on the box's glibc, so a build host one point release ahead puts every promote there. `elf::glibc_floor` reads .gnu.version_r rather than scanning for GLIBC_ strings. The scan is four lines and finds any such string that is merely data, and a false positive here refuses a promote that would have worked; a bundle test covers exactly that case. Differential-tested against readelf over 306 binaries on this host. GlibcVersion is two numbers because as strings "2.4" > "2.39", which is precisely the shape glibc's version names take. The floor is computed in the digest walk, which already reads every byte of every file, and only files whose first bytes are the ELF magic are buffered. Skipped and logged in all three cases where there is nothing to compare: no declared libc, no floor in the bundle, or a declared libc that is not a version. Cannot verify is not known bad, the same call arch_guard_script makes for an unmapped arch.
Author: Max Johnson <me@maxj.phd> · 2026-08-25 13:53 UTC
Signed with PGP, not checked
Commit: a384d1dfe40cf96019cc35618ae33810a6eba2e2
Parent: 4b34092
7 files changed, +641 insertions, -22 deletions
M sando/Cargo.lock +17 -17
@@ -1228,7 +1228,7 @@
1228 1228
1229 1229 [[package]]
1230 1230 name = "sando-daemon"
1231 - version = "0.2.11"
1231 + version = "0.2.12"
1232 1232 dependencies = [
1233 1233 "anyhow",
1234 1234 "async-trait",
@@ -2266,6 +2266,22 @@
2266 2266 source = "registry+https://github.com/rust-lang/crates.io-index"
2267 2267 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
2268 2268
2269 + [[patch.unused]]
2270 + name = "docengine"
2271 + version = "0.7.0"
2272 +
2273 + [[patch.unused]]
2274 + name = "quasi-type"
2275 + version = "0.1.0"
2276 +
2277 + [[patch.unused]]
2278 + name = "synckit-client"
2279 + version = "0.9.1"
2280 +
2281 + [[patch.unused]]
2282 + name = "synckit-config"
2283 + version = "0.2.0"
2284 +
2269 2285 [[patch.unused]]
2270 2286 name = "quasi-axum"
2271 2287 version = "0.56.0"
@@ -2313,19 +2329,3 @@
2313 2329 [[patch.unused]]
2314 2330 name = "tagtree"
2315 2331 version = "0.4.1"
2316 -
2317 - [[patch.unused]]
2318 - name = "quasi-type"
2319 - version = "0.1.0"
2320 -
2321 - [[patch.unused]]
2322 - name = "synckit-client"
2323 - version = "0.9.1"
2324 -
2325 - [[patch.unused]]
2326 - name = "synckit-config"
2327 - version = "0.2.0"
2328 -
2329 - [[patch.unused]]
2330 - name = "docengine"
2331 - version = "0.7.0"
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "sando-daemon"
3 - version = "0.2.11"
3 + version = "0.2.12"
4 4 edition = "2024"
5 5 license = "MIT"
6 6
@@ -32,6 +32,14 @@
32 32 pub struct BundleDigest {
33 33 /// sha256 of the manifest text, lowercase hex, 64 chars.
34 34 pub full: String,
35 + /// The highest glibc version any binary in this bundle requires, or `None`
36 + /// when nothing in it states one (all static, or nothing is an ELF this
37 + /// parser reads). The oldest glibc that can load this bundle.
38 + ///
39 + /// Computed in the same walk as the digest because that walk already reads
40 + /// every byte of every file: the floor is free here and would cost a second
41 + /// pass over hundreds of MB anywhere else.
42 + pub glibc_floor: Option<crate::elf::GlibcVersion>,
35 43 /// The manifest text itself, ready to write to `<bundle>/MANIFEST`.
36 44 pub manifest: String,
37 45 }
@@ -82,8 +90,13 @@
82 90 rows.sort_by(|a, b| a.0.cmp(&b.0));
83 91
84 92 let mut manifest = String::new();
93 + let mut glibc_floor: Option<crate::elf::GlibcVersion> = None;
85 94 for (rel, abs) in &rows {
86 - let hash = hash_file(abs).with_context(|| format!("hashing {}", abs.display()))?;
95 + let (hash, floor) =
96 + hash_and_floor(abs).with_context(|| format!("hashing {}", abs.display()))?;
97 + if let Some(f) = floor {
98 + glibc_floor = Some(glibc_floor.map_or(f, |b: crate::elf::GlibcVersion| b.max(f)));
99 + }
87 100 // Two spaces between hash and path, matching sha256sum's format so the
88 101 // manifest is checkable with standard tools on a node.
89 102 manifest.push_str(&hash);
@@ -93,7 +106,11 @@
93 106 }
94 107
95 108 let full = hex(&Sha256::digest(manifest.as_bytes()));
96 - Ok(BundleDigest { full, manifest })
109 + Ok(BundleDigest {
110 + full,
111 + glibc_floor,
112 + manifest,
113 + })
97 114 }
98 115
99 116 /// Recursively collect regular-file paths under `dir`. Symlinks are not
@@ -113,19 +130,43 @@
113 130 Ok(())
114 131 }
115 132
116 - fn hash_file(path: &Path) -> std::io::Result<String> {
133 + /// Hash one file and, if it is an ELF, read its glibc floor out of the same
134 + /// bytes.
135 + ///
136 + /// The two jobs share a read because a release bundle is hundreds of MB and
137 + /// reading it twice to answer two questions about it is the kind of cost that
138 + /// is invisible until a promote takes a minute longer than it should.
139 + ///
140 + /// Only the ELF header is buffered for the floor. `glibc_floor` needs the
141 + /// section headers and one string table, which live at arbitrary offsets, so a
142 + /// file that looks like an ELF is read into memory once; everything else is
143 + /// streamed and never held. That is deliberate: a bundle's assets are the bulk
144 + /// of its bytes and none of them are ELFs.
145 + fn hash_and_floor(path: &Path) -> std::io::Result<(String, Option<crate::elf::GlibcVersion>)> {
117 146 use std::io::Read;
118 147 let mut file = std::fs::File::open(path)?;
119 148 let mut hasher = Sha256::new();
120 149 let mut buf = vec![0u8; 64 * 1024].into_boxed_slice();
150 + let mut whole: Option<Vec<u8>> = None;
151 + let mut first = true;
121 152 loop {
122 153 let n = file.read(&mut buf)?;
123 154 if n == 0 {
124 155 break;
125 156 }
126 157 hasher.update(&buf[..n]);
158 + if first {
159 + first = false;
160 + if buf[..n].starts_with(b"\x7fELF") {
161 + whole = Some(Vec::with_capacity(n));
162 + }
163 + }
164 + if let Some(w) = whole.as_mut() {
165 + w.extend_from_slice(&buf[..n]);
166 + }
127 167 }
128 - Ok(hex(&hasher.finalize()))
168 + let floor = whole.as_deref().and_then(crate::elf::glibc_floor);
169 + Ok((hex(&hasher.finalize()), floor))
129 170 }
130 171
131 172 /// Relative path as a forward-slash string, so a manifest built on one OS reads
@@ -244,6 +285,61 @@
244 285 );
245 286 }
246 287
288 + /// The floor is the highest across the bundle, not the first one found.
289 + ///
290 + /// A bundle is a primary binary plus companions, and the companion is
291 + /// routinely built from different source than the server (mnw-cli ships in
292 + /// the same promote). Taking the maximum is what makes the number a property
293 + /// of the bundle rather than of whichever file the walk reached first.
294 + #[tokio::test]
295 + async fn the_floor_is_the_highest_across_every_binary_in_the_bundle() {
296 + let dir = tempfile::tempdir().unwrap();
297 + write(dir.path(), "assets/style.css", b"body{}").await;
298 + write(
299 + dir.path(),
300 + "notes.txt",
301 + b"GLIBC_9.99 as data, not a requirement",
302 + )
303 + .await;
304 + let plain = digest_dir(dir.path()).await.unwrap();
305 + assert_eq!(
306 + plain.glibc_floor, None,
307 + "a bundle of non-ELF files states no floor, and the literal string in \
308 + notes.txt must not be mistaken for a requirement"
309 + );
310 +
311 + // The test binary is a real ELF built on this host. Placed twice, the
312 + // floor must equal its own rather than doubling or resetting.
313 + let Ok(exe) = std::fs::read(std::env::current_exe().unwrap()) else {
314 + return;
315 + };
316 + let Some(own) = crate::elf::glibc_floor(&exe) else {
317 + return;
318 + };
319 + write(dir.path(), "makenotwork", &exe).await;
320 + write(dir.path(), "companions/mnw-cli", &exe).await;
321 + let with_bins = digest_dir(dir.path()).await.unwrap();
322 + assert_eq!(with_bins.glibc_floor, Some(own));
323 + }
324 +
325 + /// Reading the floor must not change what the bundle IS. The fixture test
326 + /// above covers the manifest text; this covers the digest across a bundle
327 + /// that actually contains an ELF.
328 + #[tokio::test]
329 + async fn computing_the_floor_does_not_disturb_the_digest() {
330 + let dir = tempfile::tempdir().unwrap();
331 + let exe = std::fs::read(std::env::current_exe().unwrap()).unwrap();
332 + write(dir.path(), "bin", &exe).await;
333 + write(dir.path(), "static/app.css", b"body{}").await;
334 + let d = digest_dir(dir.path()).await.unwrap();
335 + let expected_lines = 2;
336 + assert_eq!(d.manifest.lines().count(), expected_lines);
337 + // The digest is the hash of the manifest text and nothing else, so it
338 + // must be reproducible from the manifest alone.
339 + let rehash = hex(&Sha256::digest(d.manifest.as_bytes()));
340 + assert_eq!(d.full, rehash);
341 + }
342 +
247 343 #[tokio::test]
248 344 async fn manifest_file_is_excluded_from_its_own_digest() {
249 345 let dir = tempfile::tempdir().unwrap();
@@ -411,6 +411,65 @@
411 411 }
412 412 }
413 413
414 + /// Refuse a bundle whose glibc floor is above what the node declares, before
415 + /// the rsync.
416 + ///
417 + /// The weak, early half of a pair. `ldd_guard_script` runs the node's own loader
418 + /// against the actual bytes one step before the symlink swap, which covers every
419 + /// shared library and every symbol version rather than glibc alone. Nothing here
420 + /// replaces it, and a bundle that passes this can still fail that.
421 + ///
422 + /// What this adds is *when*. The loader check happens after the bundle is built
423 + /// and rsynced; this happens before either, so the subset of failures that two
424 + /// declared numbers already prove is refused at the start of the promote instead
425 + /// of most of the way through it. The zero-margin state on production makes that
426 + /// subset a live one: three of its five binaries sit exactly on the box's glibc,
427 + /// so a build host drifting one point release ahead puts every promote here.
428 + ///
429 + /// Skipped, and logged as skipped, in all three cases where there is nothing to
430 + /// compare: the node declares no `libc`, the bundle states no floor (static, or
431 + /// no ELF this parser reads), or the declared `libc` is not a version string.
432 + /// The last is a config typo rather than a bad bundle, and refusing a deploy
433 + /// over it would be answering the wrong question loudly.
434 + async fn check_bundle_fits_node(node: &Node, staged_release_dir: &Path) -> Result<()> {
435 + let Some(declared) = node.libc.as_deref() else {
436 + return Ok(());
437 + };
438 + let Some(node_libc) = crate::elf::GlibcVersion::parse(declared) else {
439 + tracing::warn!(
440 + node = %node.name,
441 + declared,
442 + "deploy: node's declared libc is not a version; glibc floor not compared"
443 + );
444 + return Ok(());
445 + };
446 + let digest = crate::bundle::digest_dir(staged_release_dir)
447 + .await
448 + .context("reading the staged bundle's glibc floor")
449 + .context(FailureStage::BeforeSwap)?;
450 + let Some(floor) = digest.glibc_floor else {
451 + tracing::info!(
452 + node = %node.name,
453 + "deploy: bundle states no glibc floor; nothing to compare"
454 + );
455 + return Ok(());
456 + };
457 + if floor > node_libc {
458 + return Err(anyhow::anyhow!(
459 + "this bundle needs glibc {floor} and `{node}` declares {node_libc}; \
460 + refusing to ship a binary the node cannot load. Either the build host \
461 + drifted ahead of the node, or the node's declared libc is stale",
462 + node = node.name,
463 + ))
464 + .context(FailureStage::BeforeSwap);
465 + }
466 + tracing::info!(
467 + node = %node.name,
468 + "deploy: glibc floor {floor} fits the node's {node_libc}"
469 + );
470 + Ok(())
471 + }
472 +
414 473 async fn deploy_remote(
415 474 executor: &dyn Executor,
416 475 node: &Node,
@@ -429,6 +488,11 @@
429 488 // naming the cause. Cheap: one shell round-trip that reads /etc/os-release.
430 489 check_node_identity(executor, node).await?;
431 490
491 + // And that the bundle could load there at all, from two numbers, before the
492 + // bytes move. The `ldd` guard below asks the stronger question on the node
493 + // itself; this one is only earlier.
494 + check_bundle_fits_node(node, staged_release_dir).await?;
495 +
432 496 tracing::info!(node = %node.name, version, release_id, "deploy: mkdir release dir");
433 497 run_checked(
434 498 executor,
@@ -892,6 +956,79 @@
892 956 }
893 957 }
894 958
959 + /// A node that declares a glibc older than the bundle needs is refused
960 + /// before the rsync, and the message names both numbers so the operator
961 + /// knows which side to fix.
962 + #[tokio::test]
963 + async fn a_bundle_above_the_node_s_declared_glibc_is_refused_before_the_rsync() {
964 + let dir = tempfile::tempdir().unwrap();
965 + let exe = std::fs::read(std::env::current_exe().unwrap()).unwrap();
966 + std::fs::write(dir.path().join("bin"), &exe).unwrap();
967 + let Some(floor) = crate::elf::glibc_floor(&exe) else {
968 + return; // a static test binary states no floor; nothing to compare
969 + };
970 +
971 + let mut node = node_on(None);
972 + node.libc = Some("2.0".into()); // older than anything real
973 + let err = check_bundle_fits_node(&node, dir.path())
974 + .await
975 + .expect_err("a bundle above the node's glibc must be refused");
976 + // `{:#}` walks the context chain: the outermost context is the
977 + // `FailureStage`, whose Display is the operator-facing "nothing moved"
978 + // line, and the cause below it is the reason.
979 + let msg = format!("{err:#}");
980 + assert!(
981 + msg.contains(&floor.to_string()) && msg.contains("2.0"),
982 + "the refusal must name both numbers: {msg}"
983 + );
984 + assert_eq!(
985 + stage_of(&err),
986 + Some(FailureStage::BeforeSwap),
987 + "refusing here must be recoverable: nothing has moved yet"
988 + );
989 + }
990 +
991 + #[tokio::test]
992 + async fn a_bundle_within_the_node_s_declared_glibc_passes() {
993 + let dir = tempfile::tempdir().unwrap();
994 + let exe = std::fs::read(std::env::current_exe().unwrap()).unwrap();
995 + std::fs::write(dir.path().join("bin"), &exe).unwrap();
996 +
997 + let mut node = node_on(None);
998 + node.libc = Some("99.0".into()); // newer than anything real
999 + check_bundle_fits_node(&node, dir.path())
1000 + .await
1001 + .expect("a bundle the node can load must pass");
1002 + }
1003 +
1004 + /// The three ways there is nothing to compare. All three pass, because
1005 + /// "cannot verify" is not "known bad" — the same call `arch_guard_script`
1006 + /// makes for an unmapped architecture.
1007 + #[tokio::test]
1008 + async fn nothing_to_compare_is_a_pass_not_a_refusal() {
1009 + let dir = tempfile::tempdir().unwrap();
1010 + let exe = std::fs::read(std::env::current_exe().unwrap()).unwrap();
1011 + std::fs::write(dir.path().join("bin"), &exe).unwrap();
1012 +
1013 + // 1. The node declares no libc.
1014 + let node = node_on(None);
1015 + check_bundle_fits_node(&node, dir.path()).await.unwrap();
1016 +
1017 + // 2. The node's declared libc is not a version (a config typo).
1018 + let mut typo = node_on(None);
1019 + typo.libc = Some("noble".into());
1020 + check_bundle_fits_node(&typo, dir.path()).await.unwrap();
1021 +
1022 + // 3. The bundle holds no ELF, so it states no floor.
1023 + let empty = tempfile::tempdir().unwrap();
1024 + std::fs::write(empty.path().join("style.css"), b"body{}").unwrap();
1025 + let mut strict = node_on(None);
1026 + strict.libc = Some("2.0".into());
1027 + check_bundle_fits_node(&strict, empty.path())
1028 + .await
1029 + .expect("a bundle with no binaries has no floor to exceed");
1030 + }
1031 +
895 1032 #[test]
896 1033 fn matching_platforms_are_placeable() {
897 1034 let node = node_on(Some("linux/aarch64"));
@@ -21,6 +21,7 @@
21 21 pub mod db;
22 22 pub mod deploy;
23 23 pub mod domain;
24 + pub mod elf;
24 25 pub mod error;
25 26 pub mod events;
26 27 pub mod gates;
@@ -1,0 +1,319 @@
1 + //! The glibc floor of a binary, read out of its ELF version requirements.
2 + //!
3 + //! A dynamically linked binary records which symbol versions it needs in
4 + //! `.gnu.version_r`, as a list of `GLIBC_x.y` names per shared object. The
5 + //! highest of those is the oldest glibc that can load it, and comparing it
6 + //! against a node's glibc answers "could this ever start there" before anything
7 + //! is built or moved.
8 + //!
9 + //! ## This is the weaker check, deliberately, and it runs first
10 + //!
11 + //! [`crate::deploy`]'s `ldd_guard_script` already asks the stronger question on
12 + //! the node: it runs that machine's own loader against the actual bytes, which
13 + //! covers every shared library and every symbol version rather than glibc
14 + //! alone, and answers "will this exec here" instead of "is this number smaller
15 + //! than that one". Nothing here replaces it and nothing here weakens it.
16 + //!
17 + //! What this buys is *when*. The loader check happens on the node, after the
18 + //! rsync, one step before the symlink swap. By then the bytes are built and
19 + //! moved. A number comparison can happen before either, so the class of failure
20 + //! that is knowable from two declared numbers is refused at the start of a
21 + //! promote rather than most of the way through it.
22 + //!
23 + //! ## Reading the section rather than scanning for strings
24 + //!
25 + //! `GLIBC_2.39` appears in `.dynstr` as a plain string, so a byte scan of the
26 + //! whole file finds it and is four lines long. It also finds any such string
27 + //! that is merely *data* — a version this binary embeds for some other reason,
28 + //! anything in a bundled asset — and a false positive here refuses a promote
29 + //! that would have worked. Parsing the section that actually states the
30 + //! requirement costs a hundred lines and cannot be fooled that way.
31 + //!
32 + //! ## What it does not read
33 + //!
34 + //! ELF64 little-endian only, which is the whole fleet (x86_64 and aarch64).
35 + //! Anything else, and anything that is not an ELF at all, returns `None`:
36 + //! "cannot verify" is not "known bad", the same call `arch_guard_script` makes
37 + //! for an unmapped architecture. A static binary has no `.gnu.version_r` and
38 + //! also returns `None`, which is correct rather than a gap: it needs no glibc.
39 + //!
40 + //! <!-- wiki: host-base-images -->
41 +
42 + use std::cmp::Ordering;
43 + use std::fmt;
44 +
45 + /// A glibc version as two numbers, so `2.9` sorts below `2.39` rather than
46 + /// above it the way the strings do.
47 + ///
48 + /// That is not a hypothetical: string comparison puts `2.4` above `2.39`, and
49 + /// glibc's version names are exactly the shape where it goes wrong.
50 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51 + pub struct GlibcVersion {
52 + major: u32,
53 + minor: u32,
54 + }
55 +
56 + impl GlibcVersion {
57 + /// Parse `2.39`, or a whole `GLIBC_2.39` version name.
58 + ///
59 + /// Rejects anything else, including the other version names that share the
60 + /// section (`GCC_3.0`, `GLIBCXX_3.4`), because only glibc's are being
61 + /// compared against a node's `ldd --version`.
62 + pub fn parse(s: &str) -> Option<Self> {
63 + let s = s.strip_prefix("GLIBC_").unwrap_or(s);
64 + let (major, minor) = s.split_once('.')?;
65 + // A trailing third component (`2.39.1`) is not a shape glibc uses in a
66 + // version name; refuse rather than silently reading the first two.
67 + if minor.contains('.') {
68 + return None;
69 + }
70 + Some(Self {
71 + major: major.parse().ok()?,
72 + minor: minor.parse().ok()?,
73 + })
74 + }
75 + }
76 +
77 + impl Ord for GlibcVersion {
78 + fn cmp(&self, other: &Self) -> Ordering {
79 + (self.major, self.minor).cmp(&(other.major, other.minor))
80 + }
81 + }
82 +
83 + impl PartialOrd for GlibcVersion {
84 + fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
85 + Some(self.cmp(other))
86 + }
87 + }
88 +
89 + impl fmt::Display for GlibcVersion {
90 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 + write!(f, "{}.{}", self.major, self.minor)
92 + }
93 + }
94 +
95 + const SHT_GNU_VERNEED: u32 = 0x6fff_fffe;
96 + const SHDR_LEN: usize = 64;
97 +
98 + /// The highest `GLIBC_` version this ELF requires, or `None` when there is
99 + /// nothing to read: not an ELF, not ELF64 little-endian, statically linked, or
100 + /// carrying no glibc version requirements.
101 + ///
102 + /// Never errors. Every unreadable shape is a `None` rather than a failure,
103 + /// because the caller's question is "do I know this cannot run there" and an
104 + /// unparseable file is not evidence that it cannot.
105 + pub fn glibc_floor(bytes: &[u8]) -> Option<GlibcVersion> {
106 + // e_ident: magic, then EI_CLASS (2 = ELF64) and EI_DATA (1 = little-endian).
107 + if bytes.len() < SHDR_LEN || &bytes[..4] != b"\x7fELF" || bytes[4] != 2 || bytes[5] != 1 {
108 + return None;
109 + }
110 + let e_shoff = u64_at(bytes, 0x28)? as usize;
111 + let e_shentsize = u16_at(bytes, 0x3A)? as usize;
112 + let e_shnum = u16_at(bytes, 0x3C)? as usize;
113 + // A section header table whose entries are not the size this parser knows is
114 + // not one to walk with a fixed stride.
115 + if e_shentsize != SHDR_LEN || e_shoff == 0 || e_shnum == 0 {
116 + return None;
117 + }
118 +
119 + let shdr = |i: usize| -> Option<&[u8]> {
120 + let start = e_shoff.checked_add(i.checked_mul(SHDR_LEN)?)?;
121 + bytes.get(start..start.checked_add(SHDR_LEN)?)
122 + };
123 +
124 + let mut best: Option<GlibcVersion> = None;
125 + for i in 0..e_shnum {
126 + let sh = shdr(i)?;
127 + if u32_at(sh, 4)? != SHT_GNU_VERNEED {
128 + continue;
129 + }
130 + let vn_off = u64_at(sh, 24)? as usize;
131 + let vn_size = u64_at(sh, 32)? as usize;
132 + // sh_link names the string table the version names are offsets into.
133 + let strtab = shdr(u32_at(sh, 40)? as usize)?;
134 + let str_off = u64_at(strtab, 24)? as usize;
135 + let str_size = u64_at(strtab, 32)? as usize;
136 + let strs = bytes.get(str_off..str_off.checked_add(str_size)?)?;
137 + let verneed = bytes.get(vn_off..vn_off.checked_add(vn_size)?)?;
138 +
139 + for name in verneed_names(verneed, strs) {
140 + if let Some(v) = GlibcVersion::parse(&name) {
141 + best = Some(best.map_or(v, |b: GlibcVersion| b.max(v)));
142 + }
143 + }
144 + }
145 + best
146 + }
147 +
148 + /// Walk the `Verneed` chain and its `Vernaux` entries, yielding every version
149 + /// name referenced.
150 + ///
151 + /// Both chains are `next`-offset linked lists that a malformed (or hostile)
152 + /// file could point in a circle, so both are bounded by the number of entries
153 + /// the headers claim and refuse a zero `next`, which is the shape a loop takes.
154 + fn verneed_names(verneed: &[u8], strs: &[u8]) -> Vec<String> {
155 + const VERNEED_LEN: usize = 16;
156 + const VERNAUX_LEN: usize = 16;
157 + let mut names = Vec::new();
158 + let mut vn = 0usize;
159 + // One pass per entry at most; the list cannot be longer than the section.
160 + for _ in 0..=(verneed.len() / VERNEED_LEN) {
161 + let Some(entry) = vn
162 + .checked_add(VERNEED_LEN)
163 + .and_then(|end| verneed.get(vn..end))
164 + else {
165 + break;
166 + };
167 + let Some(vn_cnt) = u16_at(entry, 2) else {
168 + break;
169 + };
170 + let Some(vn_aux) = u32_at(entry, 8) else {
171 + break;
172 + };
173 + let Some(vn_next) = u32_at(entry, 12) else {
174 + break;
175 + };
176 +
177 + let Some(mut aux) = vn.checked_add(vn_aux as usize) else {
178 + break;
179 + };
180 + for _ in 0..vn_cnt {
181 + let Some(a) = aux
182 + .checked_add(VERNAUX_LEN)
183 + .and_then(|end| verneed.get(aux..end))
184 + else {
185 + break;
186 + };
187 + let Some(vna_name) = u32_at(a, 8) else { break };
188 + if let Some(name) = cstr_at(strs, vna_name as usize) {
189 + names.push(name);
190 + }
191 + let Some(vna_next) = u32_at(a, 12) else { break };
192 + if vna_next == 0 {
193 + break;
194 + }
195 + let Some(next) = aux.checked_add(vna_next as usize) else {
196 + break;
197 + };
198 + aux = next;
199 + }
200 +
201 + if vn_next == 0 {
202 + break;
203 + }
204 + let Some(next) = vn.checked_add(vn_next as usize) else {
205 + break;
206 + };
207 + vn = next;
208 + }
209 + names
210 + }
211 +
212 + fn cstr_at(strs: &[u8], off: usize) -> Option<String> {
213 + let rest = strs.get(off..)?;
214 + let end = rest.iter().position(|&b| b == 0)?;
215 + Some(String::from_utf8_lossy(&rest[..end]).into_owned())
216 + }
217 +
218 + fn u16_at(b: &[u8], off: usize) -> Option<u16> {
219 + Some(u16::from_le_bytes(b.get(off..off + 2)?.try_into().ok()?))
220 + }
221 +
222 + fn u32_at(b: &[u8], off: usize) -> Option<u32> {
223 + Some(u32::from_le_bytes(b.get(off..off + 4)?.try_into().ok()?))
224 + }
225 +
226 + fn u64_at(b: &[u8], off: usize) -> Option<u64> {
227 + Some(u64::from_le_bytes(b.get(off..off + 8)?.try_into().ok()?))
228 + }
229 +
230 + #[cfg(test)]
231 + mod tests {
232 + use super::*;
233 +
234 + #[test]
235 + fn versions_order_numerically_not_lexically() {
236 + let v = |s: &str| GlibcVersion::parse(s).expect("must parse");
237 + // The whole reason this is two numbers: as strings, "2.4" > "2.39".
238 + assert!(v("2.39") > v("2.4"));
239 + assert!(v("2.39") > v("2.9"));
240 + assert!(v("2.42") > v("2.39"));
241 + assert_eq!(v("GLIBC_2.39"), v("2.39"));
242 + assert_eq!(v("2.39").to_string(), "2.39");
243 + }
244 +
245 + #[test]
246 + fn only_glibc_version_names_parse() {
247 + // These share `.gnu.version_r` with glibc's and must not be compared
248 + // against a node's glibc.
249 + for other in [
250 + "GCC_3.0",
251 + "GLIBCXX_3.4",
252 + "CXXABI_1.3",
253 + "",
254 + "GLIBC_",
255 + "2.39.1",
256 + ] {
257 + assert!(
258 + GlibcVersion::parse(other).is_none(),
259 + "`{other}` must not read as a glibc version"
260 + );
261 + }
262 + }
263 +
264 + #[test]
265 + fn a_non_elf_reads_as_unknown_rather_than_failing() {
266 + assert_eq!(glibc_floor(b"not an elf at all"), None);
267 + assert_eq!(glibc_floor(&[]), None);
268 + // ELF32, and ELF64 big-endian: both real shapes this parser declines.
269 + let mut elf32 = vec![0u8; 128];
270 + elf32[..4].copy_from_slice(b"\x7fELF");
271 + elf32[4] = 1;
272 + elf32[5] = 1;
273 + assert_eq!(glibc_floor(&elf32), None);
274 + elf32[4] = 2;
275 + elf32[5] = 2;
276 + assert_eq!(glibc_floor(&elf32), None);
277 + }
278 +
279 + /// A truncated or malformed ELF must return `None` rather than panic. This
280 + /// walks every prefix of a real binary, which is the cheap way to cover the
281 + /// bounds checks in one test.
282 + #[test]
283 + fn truncation_at_any_length_is_unknown_rather_than_a_panic() {
284 + let Some(real) = a_real_binary() else {
285 + return;
286 + };
287 + for cut in [0, 1, 4, 16, 63, 64, 65, 1024, real.len() / 2] {
288 + let _ = glibc_floor(&real[..cut.min(real.len())]);
289 + }
290 + }
291 +
292 + /// The real thing: this test binary is an ELF built on this host, so it must
293 + /// report a floor, and that floor must be one this machine can satisfy.
294 + ///
295 + /// Skipped rather than failed where there is no readable binary to point at,
296 + /// so the suite still runs somewhere this does not apply.
297 + #[test]
298 + fn a_real_binary_reports_a_plausible_floor() {
299 + let Some(bytes) = a_real_binary() else {
300 + return;
301 + };
302 + let Some(floor) = glibc_floor(&bytes) else {
303 + // A fully static test binary is legitimate and has no floor.
304 + return;
305 + };
306 + assert!(
307 + floor > GlibcVersion::parse("2.0").expect("must parse"),
308 + "a real binary's floor should be a real version, got {floor}"
309 + );
310 + assert!(
311 + floor < GlibcVersion::parse("9.0").expect("must parse"),
312 + "a real binary's floor should not be from the future, got {floor}"
313 + );
314 + }
315 +
316 + fn a_real_binary() -> Option<Vec<u8>> {
317 + std::fs::read(std::env::current_exe().ok()?).ok()
318 + }
319 + }
@@ -1,0 +1,66 @@
1 + /// Cross-check the in-house ELF read against binutils on whatever binaries this
2 + /// machine happens to have. Not a unit test of the parser (those live beside it);
3 + /// a differential against the tool everyone else would have used.
4 + #[test]
5 + fn compare_against_readelf() {
6 + // Skip where binutils is not installed rather than failing: this is a
7 + // differential, and with nothing to differ against there is nothing to say.
8 + if std::process::Command::new("readelf")
9 + .arg("--version")
10 + .output()
11 + .is_err()
12 + {
13 + eprintln!("no readelf on this machine; skipping the differential");
14 + return;
15 + }
16 + let mut checked = 0;
17 + let candidates: Vec<String> = std::fs::read_dir("/usr/bin")
18 + .into_iter()
19 + .flatten()
20 + .flatten()
21 + .map(|e| e.path().to_string_lossy().into_owned())
22 + .take(400)
23 + .collect();
24 + for p in candidates {
25 + let Ok(bytes) = std::fs::read(&p) else {
26 + continue;
27 + };
28 + if bytes.len() < 64 || &bytes[..4] != b"\x7fELF" {
29 + continue;
30 + }
31 + let mine = sando_daemon::elf::glibc_floor(&bytes).map(|v| v.to_string());
32 + let Ok(out) = std::process::Command::new("readelf")
33 + .args(["-V", &p])
34 + .output()
35 + else {
36 + continue;
37 + };
38 + if !out.status.success() {
39 + continue;
40 + }
41 + let s = String::from_utf8_lossy(&out.stdout);
42 + // Only the version-requirement section; readelf -V also prints defined
43 + // versions, which are not what a floor is made of.
44 + let req = match s.split_once("Version needs section") {
45 + Some((_, rest)) => rest,
46 + None => "",
47 + };
48 + let mut best: Option<(u32, u32)> = None;
49 + for tok in req.split(|c: char| !(c.is_alphanumeric() || c == '.' || c == '_')) {
50 + if let Some(v) = tok.strip_prefix("GLIBC_")
51 + && let Some((a, b)) = v.split_once('.')
52 + && let (Ok(a), Ok(b)) = (a.parse::<u32>(), b.parse::<u32>())
53 + {
54 + best = Some(best.map_or((a, b), |x| x.max((a, b))));
55 + }
56 + }
57 + let theirs = best.map(|(a, b)| format!("{a}.{b}"));
58 + assert_eq!(mine, theirs, "disagreed on {p}");
59 + checked += 1;
60 + }
61 + println!("agreed with readelf on {checked} binaries");
62 + assert!(
63 + checked > 20,
64 + "only {checked} binaries compared; too few to mean anything"
65 + );
66 + }