//! 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 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(); for (rel, abs) in &rows { let hash = hash_file(abs).with_context(|| format!("hashing {}", abs.display()))?; // 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, 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(()) } fn hash_file(path: &Path) -> std::io::Result { 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(); loop { let n = file.read(&mut buf)?; if n == 0 { break; } hasher.update(&buf[..n]); } Ok(hex(&hasher.finalize())) } /// 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::*; 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)" ); } #[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); } }