//! Release-bundle content addressing. Design: wiki [[release-artifact-identity]]. //! //! A release bundle is the whole staged release dir — the primary binary plus //! `companions/` plus everything from `cfg.release_contents` (static //! assets, docs, error pages). Its identity is the **bundle digest**: the //! sha256 of a `MANIFEST` listing one ` ` line per file, //! sorted by relative path. //! //! Sorting by path makes the manifest order-independent and reproducible, and //! buys two properties for free: node-side verification is per-file, so a //! mismatch names the file that drifted rather than just reporting that //! something did; and the manifest is plain text readable on a node with no //! tooling. //! //! Hashing the *bundle* rather than the primary binary is deliberate: two //! bundles with identical binaries but differing assets must not collide — that //! is the exact drift that crash-looped prod on a missing `CDN_BASE_URL` //! (postmortem 2026-07-09 #2). use anyhow::{Context, Result}; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; /// The manifest file's name at the bundle root. Excluded from its own hash so /// a bundle that carries its MANIFEST (for node-side verification) still /// digests to the same value as the bundle before the file was written. pub const MANIFEST_NAME: &str = "MANIFEST"; /// The full bundle digest recorded on `build_runs.bundle_digest` (64 hex), and /// the 16-char prefix used as the content-addressed directory name. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BundleDigest { /// sha256 of the manifest text, lowercase hex, 64 chars. pub full: String, /// The highest glibc version any binary in this bundle requires, or `None` /// when nothing in it states one (all static, or nothing is an ELF this /// parser reads). The oldest glibc that can load this bundle. /// /// Computed in the same walk as the digest because that walk already reads /// every byte of every file: the floor is free here and would cost a second /// pass over hundreds of MB anywhere else. pub glibc_floor: Option, /// The manifest text itself, ready to write to `/MANIFEST`. pub manifest: String, } impl BundleDigest { /// The 16-hex-char prefix used in `releases//`. Full digest is /// kept in the DB; the path only needs enough to be collision-safe. pub fn short(&self) -> &str { &self.full[..16] } } /// Compute the bundle digest for the staged release dir at `root`. /// /// Walks every regular file under `root` (recursively), hashes each, and builds /// the sorted `MANIFEST`. The manifest file itself ([`MANIFEST_NAME`] at the /// root) is skipped so the digest is stable whether or not it has been written /// into the bundle yet. /// /// The walk + per-file hashing runs on a blocking thread: a release bundle is /// hundreds of MB of binary, and hashing it must not stall the async runtime. pub async fn digest_dir(root: &Path) -> Result { let root = root.to_path_buf(); tokio::task::spawn_blocking(move || digest_dir_blocking(&root)) .await .context("bundle digest task panicked")? } fn digest_dir_blocking(root: &Path) -> Result { let mut files: Vec = Vec::new(); collect_files(root, &mut files) .with_context(|| format!("walking bundle dir {}", root.display()))?; // Relative paths, sorted, so the manifest is order-independent across hosts // and filesystems (readdir order is not guaranteed). let mut rows: Vec<(String, PathBuf)> = Vec::with_capacity(files.len()); for abs in files { let rel = abs .strip_prefix(root) .with_context(|| format!("{} not under bundle root", abs.display()))?; // Skip the manifest file itself — it is not part of what it describes. if rel.as_os_str() == MANIFEST_NAME { continue; } let rel_str = rel_to_unix(rel); rows.push((rel_str, abs)); } rows.sort_by(|a, b| a.0.cmp(&b.0)); let mut manifest = String::new(); let mut glibc_floor: Option = None; for (rel, abs) in &rows { let (hash, floor) = hash_and_floor(abs).with_context(|| format!("hashing {}", abs.display()))?; if let Some(f) = floor { glibc_floor = Some(glibc_floor.map_or(f, |b: crate::elf::GlibcVersion| b.max(f))); } // Two spaces between hash and path, matching sha256sum's format so the // manifest is checkable with standard tools on a node. manifest.push_str(&hash); manifest.push_str(" "); manifest.push_str(rel); manifest.push('\n'); } let full = hex(&Sha256::digest(manifest.as_bytes())); Ok(BundleDigest { full, glibc_floor, manifest, }) } /// Recursively collect regular-file paths under `dir`. Symlinks are not /// followed: a staged bundle is a plain tree of copied files, and following /// links would let content outside the bundle leak into its identity. fn collect_files(dir: &Path, out: &mut Vec) -> std::io::Result<()> { for entry in std::fs::read_dir(dir)? { let entry = entry?; let ft = entry.file_type()?; if ft.is_dir() { collect_files(&entry.path(), out)?; } else if ft.is_file() { out.push(entry.path()); } // symlinks / other special files are intentionally ignored. } Ok(()) } /// Hash one file and, if it is an ELF, read its glibc floor out of the same /// bytes. /// /// The two jobs share a read because a release bundle is hundreds of MB and /// reading it twice to answer two questions about it is the kind of cost that /// is invisible until a promote takes a minute longer than it should. /// /// Only the ELF header is buffered for the floor. `glibc_floor` needs the /// section headers and one string table, which live at arbitrary offsets, so a /// file that looks like an ELF is read into memory once; everything else is /// streamed and never held. That is deliberate: a bundle's assets are the bulk /// of its bytes and none of them are ELFs. fn hash_and_floor(path: &Path) -> std::io::Result<(String, Option)> { use std::io::Read; let mut file = std::fs::File::open(path)?; let mut hasher = Sha256::new(); let mut buf = vec![0u8; 64 * 1024].into_boxed_slice(); let mut whole: Option> = None; let mut first = true; loop { let n = file.read(&mut buf)?; if n == 0 { break; } hasher.update(&buf[..n]); if first { first = false; if buf[..n].starts_with(b"\x7fELF") { whole = Some(Vec::with_capacity(n)); } } if let Some(w) = whole.as_mut() { w.extend_from_slice(&buf[..n]); } } let floor = whole.as_deref().and_then(crate::elf::glibc_floor); Ok((hex(&hasher.finalize()), floor)) } /// Relative path as a forward-slash string, so a manifest built on one OS reads /// the same on another. Sando builds on Linux, but keep the identity portable. fn rel_to_unix(rel: &Path) -> String { rel.components() .map(|c| c.as_os_str().to_string_lossy()) .collect::>() .join("/") } fn hex(bytes: &[u8]) -> String { use std::fmt::Write; let mut s = String::with_capacity(bytes.len() * 2); for b in bytes { let _ = write!(s, "{b:02x}"); } s } #[cfg(test)] mod tests { use super::*; /// The manifest text the shared cross-crate fixture must produce, in BOTH /// crates. /// /// Bento's `engine.rs` has the identical constant and the identical fixture. /// Bento writes this text into the artifact record at `collect`; this walker /// recomputes it from the bytes that arrive at intake, and an artifact whose /// two answers differ is refused. Producer and consumer are different walks /// in different repos, so the agreement is pinned from both ends — if either /// drifts, its own test fails and names the drift, instead of a release /// failing intake for a bundle nothing is wrong with. /// /// The nested directory is the case that matters: bento's collect used to /// list only the top level, so `migrations/` contributed nothing to the /// manifest while this walker hashed both files in it. const BUNDLE_FIXTURE_MANIFEST: &str = concat!( "e4c908e219c533fa7ad7ea1634398f9bf51637ba20717769ada545bab26d7368 migrations/001_init.sql\n", "b026fd51bae096b34672cefdb781b6585b13efb53bc301d50c305f422552a380 migrations/002_next.sql\n", "71227a7f160afca3fb3c39f448735886dda7bd366252580c2222fb87d4bb4d85 pom\n", ); /// The consumer half of the contract: what arrives is read exactly the way /// the producer described it. #[tokio::test] async fn the_shared_bundle_fixture_digests_as_the_producer_wrote_it() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); write(root, "pom", b"binary-bytes").await; write(root, "migrations/001_init.sql", b"create table a;").await; write(root, "migrations/002_next.sql", b"alter table a;").await; let d = digest_dir(root).await.unwrap(); assert_eq!(d.manifest, BUNDLE_FIXTURE_MANIFEST); } async fn write(root: &Path, rel: &str, bytes: &[u8]) { let p = root.join(rel); tokio::fs::create_dir_all(p.parent().unwrap()) .await .unwrap(); tokio::fs::write(&p, bytes).await.unwrap(); } #[tokio::test] async fn digest_is_stable_and_lists_every_file_sorted() { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); write(root, "makenotwork", b"binary bytes").await; write(root, "static/app.css", b"body{}").await; write(root, "companions/mnw-cli", b"cli bytes").await; let d = super::digest_dir(root).await.unwrap(); assert_eq!(d.full.len(), 64); assert_eq!(d.short().len(), 16); // Manifest lines are sorted by relative path. let paths: Vec<&str> = d .manifest .lines() .map(|l| l.split_once(" ").unwrap().1) .collect(); assert_eq!( paths, ["companions/mnw-cli", "makenotwork", "static/app.css"] ); } #[tokio::test] async fn digest_is_independent_of_creation_order() { let a = tempfile::tempdir().unwrap(); write(a.path(), "z.txt", b"1").await; write(a.path(), "a.txt", b"2").await; let b = tempfile::tempdir().unwrap(); write(b.path(), "a.txt", b"2").await; write(b.path(), "z.txt", b"1").await; assert_eq!( super::digest_dir(a.path()).await.unwrap().full, super::digest_dir(b.path()).await.unwrap().full, ); } #[tokio::test] async fn a_changed_asset_changes_the_digest_even_with_identical_binary() { let a = tempfile::tempdir().unwrap(); write(a.path(), "makenotwork", b"same binary").await; write(a.path(), "static/app.css", b"v1").await; let b = tempfile::tempdir().unwrap(); write(b.path(), "makenotwork", b"same binary").await; write(b.path(), "static/app.css", b"v2").await; assert_ne!( super::digest_dir(a.path()).await.unwrap().full, super::digest_dir(b.path()).await.unwrap().full, "asset drift with an identical binary must not collide (2026-07-09 #2)" ); } /// The floor is the highest across the bundle, not the first one found. /// /// A bundle is a primary binary plus companions, and the companion is /// routinely built from different source than the server (mnw-cli ships in /// the same promote). Taking the maximum is what makes the number a property /// of the bundle rather than of whichever file the walk reached first. #[tokio::test] async fn the_floor_is_the_highest_across_every_binary_in_the_bundle() { let dir = tempfile::tempdir().unwrap(); write(dir.path(), "assets/style.css", b"body{}").await; write( dir.path(), "notes.txt", b"GLIBC_9.99 as data, not a requirement", ) .await; let plain = digest_dir(dir.path()).await.unwrap(); assert_eq!( plain.glibc_floor, None, "a bundle of non-ELF files states no floor, and the literal string in \ notes.txt must not be mistaken for a requirement" ); // The test binary is a real ELF built on this host. Placed twice, the // floor must equal its own rather than doubling or resetting. let Ok(exe) = std::fs::read(std::env::current_exe().unwrap()) else { return; }; let Some(own) = crate::elf::glibc_floor(&exe) else { return; }; write(dir.path(), "makenotwork", &exe).await; write(dir.path(), "companions/mnw-cli", &exe).await; let with_bins = digest_dir(dir.path()).await.unwrap(); assert_eq!(with_bins.glibc_floor, Some(own)); } /// Reading the floor must not change what the bundle IS. The fixture test /// above covers the manifest text; this covers the digest across a bundle /// that actually contains an ELF. #[tokio::test] async fn computing_the_floor_does_not_disturb_the_digest() { let dir = tempfile::tempdir().unwrap(); let exe = std::fs::read(std::env::current_exe().unwrap()).unwrap(); write(dir.path(), "bin", &exe).await; write(dir.path(), "static/app.css", b"body{}").await; let d = digest_dir(dir.path()).await.unwrap(); let expected_lines = 2; assert_eq!(d.manifest.lines().count(), expected_lines); // The digest is the hash of the manifest text and nothing else, so it // must be reproducible from the manifest alone. let rehash = hex(&Sha256::digest(d.manifest.as_bytes())); assert_eq!(d.full, rehash); } #[tokio::test] async fn manifest_file_is_excluded_from_its_own_digest() { let dir = tempfile::tempdir().unwrap(); write(dir.path(), "makenotwork", b"bytes").await; let before = super::digest_dir(dir.path()).await.unwrap(); // Write the manifest into the bundle, as Stage B will for node-side // verification; the digest must not change. write(dir.path(), MANIFEST_NAME, before.manifest.as_bytes()).await; let after = super::digest_dir(dir.path()).await.unwrap(); assert_eq!(before.full, after.full); } }