max / makenotwork
7 files changed,
+641 insertions,
-22 deletions
| @@ -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 | + | } |