Skip to main content

max / makenotwork

14.5 KB · 355 lines History Blame Raw
1 //! Release-bundle content addressing. Design: wiki [[release-artifact-identity]].
2 //!
3 //! A release bundle is the whole staged release dir — the primary binary plus
4 //! `companions/<name>` plus everything from `cfg.release_contents` (static
5 //! assets, docs, error pages). Its identity is the **bundle digest**: the
6 //! sha256 of a `MANIFEST` listing one `<sha256> <relpath>` line per file,
7 //! sorted by relative path.
8 //!
9 //! Sorting by path makes the manifest order-independent and reproducible, and
10 //! buys two properties for free: node-side verification is per-file, so a
11 //! mismatch names the file that drifted rather than just reporting that
12 //! something did; and the manifest is plain text readable on a node with no
13 //! tooling.
14 //!
15 //! Hashing the *bundle* rather than the primary binary is deliberate: two
16 //! bundles with identical binaries but differing assets must not collide — that
17 //! is the exact drift that crash-looped prod on a missing `CDN_BASE_URL`
18 //! (postmortem 2026-07-09 #2).
19
20 use anyhow::{Context, Result};
21 use sha2::{Digest, Sha256};
22 use std::path::{Path, PathBuf};
23
24 /// The manifest file's name at the bundle root. Excluded from its own hash so
25 /// a bundle that carries its MANIFEST (for node-side verification) still
26 /// digests to the same value as the bundle before the file was written.
27 pub const MANIFEST_NAME: &str = "MANIFEST";
28
29 /// The full bundle digest recorded on `build_runs.bundle_digest` (64 hex), and
30 /// the 16-char prefix used as the content-addressed directory name.
31 #[derive(Debug, Clone, PartialEq, Eq)]
32 pub struct BundleDigest {
33 /// sha256 of the manifest text, lowercase hex, 64 chars.
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>,
43 /// The manifest text itself, ready to write to `<bundle>/MANIFEST`.
44 pub manifest: String,
45 }
46
47 impl BundleDigest {
48 /// The 16-hex-char prefix used in `releases/<digest16>/`. Full digest is
49 /// kept in the DB; the path only needs enough to be collision-safe.
50 pub fn short(&self) -> &str {
51 &self.full[..16]
52 }
53 }
54
55 /// Compute the bundle digest for the staged release dir at `root`.
56 ///
57 /// Walks every regular file under `root` (recursively), hashes each, and builds
58 /// the sorted `MANIFEST`. The manifest file itself ([`MANIFEST_NAME`] at the
59 /// root) is skipped so the digest is stable whether or not it has been written
60 /// into the bundle yet.
61 ///
62 /// The walk + per-file hashing runs on a blocking thread: a release bundle is
63 /// hundreds of MB of binary, and hashing it must not stall the async runtime.
64 pub async fn digest_dir(root: &Path) -> Result<BundleDigest> {
65 let root = root.to_path_buf();
66 tokio::task::spawn_blocking(move || digest_dir_blocking(&root))
67 .await
68 .context("bundle digest task panicked")?
69 }
70
71 fn digest_dir_blocking(root: &Path) -> Result<BundleDigest> {
72 let mut files: Vec<PathBuf> = Vec::new();
73 collect_files(root, &mut files)
74 .with_context(|| format!("walking bundle dir {}", root.display()))?;
75
76 // Relative paths, sorted, so the manifest is order-independent across hosts
77 // and filesystems (readdir order is not guaranteed).
78 let mut rows: Vec<(String, PathBuf)> = Vec::with_capacity(files.len());
79 for abs in files {
80 let rel = abs
81 .strip_prefix(root)
82 .with_context(|| format!("{} not under bundle root", abs.display()))?;
83 // Skip the manifest file itself — it is not part of what it describes.
84 if rel.as_os_str() == MANIFEST_NAME {
85 continue;
86 }
87 let rel_str = rel_to_unix(rel);
88 rows.push((rel_str, abs));
89 }
90 rows.sort_by(|a, b| a.0.cmp(&b.0));
91
92 let mut manifest = String::new();
93 let mut glibc_floor: Option<crate::elf::GlibcVersion> = None;
94 for (rel, abs) in &rows {
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 }
100 // Two spaces between hash and path, matching sha256sum's format so the
101 // manifest is checkable with standard tools on a node.
102 manifest.push_str(&hash);
103 manifest.push_str(" ");
104 manifest.push_str(rel);
105 manifest.push('\n');
106 }
107
108 let full = hex(&Sha256::digest(manifest.as_bytes()));
109 Ok(BundleDigest {
110 full,
111 glibc_floor,
112 manifest,
113 })
114 }
115
116 /// Recursively collect regular-file paths under `dir`. Symlinks are not
117 /// followed: a staged bundle is a plain tree of copied files, and following
118 /// links would let content outside the bundle leak into its identity.
119 fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
120 for entry in std::fs::read_dir(dir)? {
121 let entry = entry?;
122 let ft = entry.file_type()?;
123 if ft.is_dir() {
124 collect_files(&entry.path(), out)?;
125 } else if ft.is_file() {
126 out.push(entry.path());
127 }
128 // symlinks / other special files are intentionally ignored.
129 }
130 Ok(())
131 }
132
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>)> {
146 use std::io::Read;
147 let mut file = std::fs::File::open(path)?;
148 let mut hasher = Sha256::new();
149 let mut buf = vec![0u8; 64 * 1024].into_boxed_slice();
150 let mut whole: Option<Vec<u8>> = None;
151 let mut first = true;
152 loop {
153 let n = file.read(&mut buf)?;
154 if n == 0 {
155 break;
156 }
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 }
167 }
168 let floor = whole.as_deref().and_then(crate::elf::glibc_floor);
169 Ok((hex(&hasher.finalize()), floor))
170 }
171
172 /// Relative path as a forward-slash string, so a manifest built on one OS reads
173 /// the same on another. Sando builds on Linux, but keep the identity portable.
174 fn rel_to_unix(rel: &Path) -> String {
175 rel.components()
176 .map(|c| c.as_os_str().to_string_lossy())
177 .collect::<Vec<_>>()
178 .join("/")
179 }
180
181 fn hex(bytes: &[u8]) -> String {
182 use std::fmt::Write;
183 let mut s = String::with_capacity(bytes.len() * 2);
184 for b in bytes {
185 let _ = write!(s, "{b:02x}");
186 }
187 s
188 }
189
190 #[cfg(test)]
191 mod tests {
192 use super::*;
193
194 /// The manifest text the shared cross-crate fixture must produce, in BOTH
195 /// crates.
196 ///
197 /// Bento's `engine.rs` has the identical constant and the identical fixture.
198 /// Bento writes this text into the artifact record at `collect`; this walker
199 /// recomputes it from the bytes that arrive at intake, and an artifact whose
200 /// two answers differ is refused. Producer and consumer are different walks
201 /// in different repos, so the agreement is pinned from both ends — if either
202 /// drifts, its own test fails and names the drift, instead of a release
203 /// failing intake for a bundle nothing is wrong with.
204 ///
205 /// The nested directory is the case that matters: bento's collect used to
206 /// list only the top level, so `migrations/` contributed nothing to the
207 /// manifest while this walker hashed both files in it.
208 const BUNDLE_FIXTURE_MANIFEST: &str = concat!(
209 "e4c908e219c533fa7ad7ea1634398f9bf51637ba20717769ada545bab26d7368 migrations/001_init.sql\n",
210 "b026fd51bae096b34672cefdb781b6585b13efb53bc301d50c305f422552a380 migrations/002_next.sql\n",
211 "71227a7f160afca3fb3c39f448735886dda7bd366252580c2222fb87d4bb4d85 pom\n",
212 );
213
214 /// The consumer half of the contract: what arrives is read exactly the way
215 /// the producer described it.
216 #[tokio::test]
217 async fn the_shared_bundle_fixture_digests_as_the_producer_wrote_it() {
218 let tmp = tempfile::tempdir().unwrap();
219 let root = tmp.path();
220 write(root, "pom", b"binary-bytes").await;
221 write(root, "migrations/001_init.sql", b"create table a;").await;
222 write(root, "migrations/002_next.sql", b"alter table a;").await;
223
224 let d = digest_dir(root).await.unwrap();
225 assert_eq!(d.manifest, BUNDLE_FIXTURE_MANIFEST);
226 }
227
228 async fn write(root: &Path, rel: &str, bytes: &[u8]) {
229 let p = root.join(rel);
230 tokio::fs::create_dir_all(p.parent().unwrap())
231 .await
232 .unwrap();
233 tokio::fs::write(&p, bytes).await.unwrap();
234 }
235
236 #[tokio::test]
237 async fn digest_is_stable_and_lists_every_file_sorted() {
238 let dir = tempfile::tempdir().unwrap();
239 let root = dir.path();
240 write(root, "makenotwork", b"binary bytes").await;
241 write(root, "static/app.css", b"body{}").await;
242 write(root, "companions/mnw-cli", b"cli bytes").await;
243
244 let d = super::digest_dir(root).await.unwrap();
245 assert_eq!(d.full.len(), 64);
246 assert_eq!(d.short().len(), 16);
247 // Manifest lines are sorted by relative path.
248 let paths: Vec<&str> = d
249 .manifest
250 .lines()
251 .map(|l| l.split_once(" ").unwrap().1)
252 .collect();
253 assert_eq!(
254 paths,
255 ["companions/mnw-cli", "makenotwork", "static/app.css"]
256 );
257 }
258
259 #[tokio::test]
260 async fn digest_is_independent_of_creation_order() {
261 let a = tempfile::tempdir().unwrap();
262 write(a.path(), "z.txt", b"1").await;
263 write(a.path(), "a.txt", b"2").await;
264 let b = tempfile::tempdir().unwrap();
265 write(b.path(), "a.txt", b"2").await;
266 write(b.path(), "z.txt", b"1").await;
267 assert_eq!(
268 super::digest_dir(a.path()).await.unwrap().full,
269 super::digest_dir(b.path()).await.unwrap().full,
270 );
271 }
272
273 #[tokio::test]
274 async fn a_changed_asset_changes_the_digest_even_with_identical_binary() {
275 let a = tempfile::tempdir().unwrap();
276 write(a.path(), "makenotwork", b"same binary").await;
277 write(a.path(), "static/app.css", b"v1").await;
278 let b = tempfile::tempdir().unwrap();
279 write(b.path(), "makenotwork", b"same binary").await;
280 write(b.path(), "static/app.css", b"v2").await;
281 assert_ne!(
282 super::digest_dir(a.path()).await.unwrap().full,
283 super::digest_dir(b.path()).await.unwrap().full,
284 "asset drift with an identical binary must not collide (2026-07-09 #2)"
285 );
286 }
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
343 #[tokio::test]
344 async fn manifest_file_is_excluded_from_its_own_digest() {
345 let dir = tempfile::tempdir().unwrap();
346 write(dir.path(), "makenotwork", b"bytes").await;
347 let before = super::digest_dir(dir.path()).await.unwrap();
348 // Write the manifest into the bundle, as Stage B will for node-side
349 // verification; the digest must not change.
350 write(dir.path(), MANIFEST_NAME, before.manifest.as_bytes()).await;
351 let after = super::digest_dir(dir.path()).await.unwrap();
352 assert_eq!(before.full, after.full);
353 }
354 }
355