Skip to main content

max / makenotwork

14.4 KB · 354 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 //! drift is what crash-loops a node on a missing `CDN_BASE_URL`.
18
19 use anyhow::{Context, Result};
20 use sha2::{Digest, Sha256};
21 use std::path::{Path, PathBuf};
22
23 /// The manifest file's name at the bundle root. Excluded from its own hash so
24 /// a bundle that carries its MANIFEST (for node-side verification) still
25 /// digests to the same value as the bundle before the file was written.
26 pub const MANIFEST_NAME: &str = "MANIFEST";
27
28 /// The full bundle digest recorded on `build_runs.bundle_digest` (64 hex), and
29 /// the 16-char prefix used as the content-addressed directory name.
30 #[derive(Debug, Clone, PartialEq, Eq)]
31 pub struct BundleDigest {
32 /// sha256 of the manifest text, lowercase hex, 64 chars.
33 pub full: String,
34 /// The highest glibc version any binary in this bundle requires, or `None`
35 /// when nothing in it states one (all static, or nothing is an ELF this
36 /// parser reads). The oldest glibc that can load this bundle.
37 ///
38 /// Computed in the same walk as the digest because that walk already reads
39 /// every byte of every file: the floor is free here and would cost a second
40 /// pass over hundreds of MB anywhere else.
41 pub glibc_floor: Option<crate::elf::GlibcVersion>,
42 /// The manifest text itself, ready to write to `<bundle>/MANIFEST`.
43 pub manifest: String,
44 }
45
46 impl BundleDigest {
47 /// The 16-hex-char prefix used in `releases/<digest16>/`. Full digest is
48 /// kept in the DB; the path only needs enough to be collision-safe.
49 pub fn short(&self) -> &str {
50 &self.full[..16]
51 }
52 }
53
54 /// Compute the bundle digest for the staged release dir at `root`.
55 ///
56 /// Walks every regular file under `root` (recursively), hashes each, and builds
57 /// the sorted `MANIFEST`. The manifest file itself ([`MANIFEST_NAME`] at the
58 /// root) is skipped so the digest is stable whether or not it has been written
59 /// into the bundle yet.
60 ///
61 /// The walk + per-file hashing runs on a blocking thread: a release bundle is
62 /// hundreds of MB of binary, and hashing it must not stall the async runtime.
63 pub async fn digest_dir(root: &Path) -> Result<BundleDigest> {
64 let root = root.to_path_buf();
65 tokio::task::spawn_blocking(move || digest_dir_blocking(&root))
66 .await
67 .context("bundle digest task panicked")?
68 }
69
70 fn digest_dir_blocking(root: &Path) -> Result<BundleDigest> {
71 let mut files: Vec<PathBuf> = Vec::new();
72 collect_files(root, &mut files)
73 .with_context(|| format!("walking bundle dir {}", root.display()))?;
74
75 // Relative paths, sorted, so the manifest is order-independent across hosts
76 // and filesystems (readdir order is not guaranteed).
77 let mut rows: Vec<(String, PathBuf)> = Vec::with_capacity(files.len());
78 for abs in files {
79 let rel = abs
80 .strip_prefix(root)
81 .with_context(|| format!("{} not under bundle root", abs.display()))?;
82 // Skip the manifest file itself — it is not part of what it describes.
83 if rel.as_os_str() == MANIFEST_NAME {
84 continue;
85 }
86 let rel_str = rel_to_unix(rel);
87 rows.push((rel_str, abs));
88 }
89 rows.sort_by(|a, b| a.0.cmp(&b.0));
90
91 let mut manifest = String::new();
92 let mut glibc_floor: Option<crate::elf::GlibcVersion> = None;
93 for (rel, abs) in &rows {
94 let (hash, floor) =
95 hash_and_floor(abs).with_context(|| format!("hashing {}", abs.display()))?;
96 if let Some(f) = floor {
97 glibc_floor = Some(glibc_floor.map_or(f, |b: crate::elf::GlibcVersion| b.max(f)));
98 }
99 // Two spaces between hash and path, matching sha256sum's format so the
100 // manifest is checkable with standard tools on a node.
101 manifest.push_str(&hash);
102 manifest.push_str(" ");
103 manifest.push_str(rel);
104 manifest.push('\n');
105 }
106
107 let full = hex(&Sha256::digest(manifest.as_bytes()));
108 Ok(BundleDigest {
109 full,
110 glibc_floor,
111 manifest,
112 })
113 }
114
115 /// Recursively collect regular-file paths under `dir`. Symlinks are not
116 /// followed: a staged bundle is a plain tree of copied files, and following
117 /// links would let content outside the bundle leak into its identity.
118 fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
119 for entry in std::fs::read_dir(dir)? {
120 let entry = entry?;
121 let ft = entry.file_type()?;
122 if ft.is_dir() {
123 collect_files(&entry.path(), out)?;
124 } else if ft.is_file() {
125 out.push(entry.path());
126 }
127 // symlinks / other special files are intentionally ignored.
128 }
129 Ok(())
130 }
131
132 /// Hash one file and, if it is an ELF, read its glibc floor out of the same
133 /// bytes.
134 ///
135 /// The two jobs share a read because a release bundle is hundreds of MB and
136 /// reading it twice to answer two questions about it is the kind of cost that
137 /// is invisible until a promote takes a minute longer than it should.
138 ///
139 /// Only the ELF header is buffered for the floor. `glibc_floor` needs the
140 /// section headers and one string table, which live at arbitrary offsets, so a
141 /// file that looks like an ELF is read into memory once; everything else is
142 /// streamed and never held. That is deliberate: a bundle's assets are the bulk
143 /// of its bytes and none of them are ELFs.
144 fn hash_and_floor(path: &Path) -> std::io::Result<(String, Option<crate::elf::GlibcVersion>)> {
145 use std::io::Read;
146 let mut file = std::fs::File::open(path)?;
147 let mut hasher = Sha256::new();
148 let mut buf = vec![0u8; 64 * 1024].into_boxed_slice();
149 let mut whole: Option<Vec<u8>> = None;
150 let mut first = true;
151 loop {
152 let n = file.read(&mut buf)?;
153 if n == 0 {
154 break;
155 }
156 hasher.update(&buf[..n]);
157 if first {
158 first = false;
159 if buf[..n].starts_with(b"\x7fELF") {
160 whole = Some(Vec::with_capacity(n));
161 }
162 }
163 if let Some(w) = whole.as_mut() {
164 w.extend_from_slice(&buf[..n]);
165 }
166 }
167 let floor = whole.as_deref().and_then(crate::elf::glibc_floor);
168 Ok((hex(&hasher.finalize()), floor))
169 }
170
171 /// Relative path as a forward-slash string, so a manifest built on one OS reads
172 /// the same on another. Sando builds on Linux, but keep the identity portable.
173 fn rel_to_unix(rel: &Path) -> String {
174 rel.components()
175 .map(|c| c.as_os_str().to_string_lossy())
176 .collect::<Vec<_>>()
177 .join("/")
178 }
179
180 fn hex(bytes: &[u8]) -> String {
181 use std::fmt::Write;
182 let mut s = String::with_capacity(bytes.len() * 2);
183 for b in bytes {
184 let _ = write!(s, "{b:02x}");
185 }
186 s
187 }
188
189 #[cfg(test)]
190 mod tests {
191 use super::*;
192
193 /// The manifest text the shared cross-crate fixture must produce, in BOTH
194 /// crates.
195 ///
196 /// Bento's `engine.rs` has the identical constant and the identical fixture.
197 /// Bento writes this text into the artifact record at `collect`; this walker
198 /// recomputes it from the bytes that arrive at intake, and an artifact whose
199 /// two answers differ is refused. Producer and consumer are different walks
200 /// in different repos, so the agreement is pinned from both ends — if either
201 /// drifts, its own test fails and names the drift, instead of a release
202 /// failing intake for a bundle nothing is wrong with.
203 ///
204 /// The nested directory is the case that matters: bento's collect used to
205 /// list only the top level, so `migrations/` contributed nothing to the
206 /// manifest while this walker hashed both files in it.
207 const BUNDLE_FIXTURE_MANIFEST: &str = concat!(
208 "e4c908e219c533fa7ad7ea1634398f9bf51637ba20717769ada545bab26d7368 migrations/001_init.sql\n",
209 "b026fd51bae096b34672cefdb781b6585b13efb53bc301d50c305f422552a380 migrations/002_next.sql\n",
210 "71227a7f160afca3fb3c39f448735886dda7bd366252580c2222fb87d4bb4d85 pom\n",
211 );
212
213 /// The consumer half of the contract: what arrives is read exactly the way
214 /// the producer described it.
215 #[tokio::test]
216 async fn the_shared_bundle_fixture_digests_as_the_producer_wrote_it() {
217 let tmp = tempfile::tempdir().unwrap();
218 let root = tmp.path();
219 write(root, "pom", b"binary-bytes").await;
220 write(root, "migrations/001_init.sql", b"create table a;").await;
221 write(root, "migrations/002_next.sql", b"alter table a;").await;
222
223 let d = digest_dir(root).await.unwrap();
224 assert_eq!(d.manifest, BUNDLE_FIXTURE_MANIFEST);
225 }
226
227 async fn write(root: &Path, rel: &str, bytes: &[u8]) {
228 let p = root.join(rel);
229 tokio::fs::create_dir_all(p.parent().unwrap())
230 .await
231 .unwrap();
232 tokio::fs::write(&p, bytes).await.unwrap();
233 }
234
235 #[tokio::test]
236 async fn digest_is_stable_and_lists_every_file_sorted() {
237 let dir = tempfile::tempdir().unwrap();
238 let root = dir.path();
239 write(root, "makenotwork", b"binary bytes").await;
240 write(root, "static/app.css", b"body{}").await;
241 write(root, "companions/mnw-cli", b"cli bytes").await;
242
243 let d = super::digest_dir(root).await.unwrap();
244 assert_eq!(d.full.len(), 64);
245 assert_eq!(d.short().len(), 16);
246 // Manifest lines are sorted by relative path.
247 let paths: Vec<&str> = d
248 .manifest
249 .lines()
250 .map(|l| l.split_once(" ").unwrap().1)
251 .collect();
252 assert_eq!(
253 paths,
254 ["companions/mnw-cli", "makenotwork", "static/app.css"]
255 );
256 }
257
258 #[tokio::test]
259 async fn digest_is_independent_of_creation_order() {
260 let a = tempfile::tempdir().unwrap();
261 write(a.path(), "z.txt", b"1").await;
262 write(a.path(), "a.txt", b"2").await;
263 let b = tempfile::tempdir().unwrap();
264 write(b.path(), "a.txt", b"2").await;
265 write(b.path(), "z.txt", b"1").await;
266 assert_eq!(
267 super::digest_dir(a.path()).await.unwrap().full,
268 super::digest_dir(b.path()).await.unwrap().full,
269 );
270 }
271
272 #[tokio::test]
273 async fn a_changed_asset_changes_the_digest_even_with_identical_binary() {
274 let a = tempfile::tempdir().unwrap();
275 write(a.path(), "makenotwork", b"same binary").await;
276 write(a.path(), "static/app.css", b"v1").await;
277 let b = tempfile::tempdir().unwrap();
278 write(b.path(), "makenotwork", b"same binary").await;
279 write(b.path(), "static/app.css", b"v2").await;
280 assert_ne!(
281 super::digest_dir(a.path()).await.unwrap().full,
282 super::digest_dir(b.path()).await.unwrap().full,
283 "asset drift with an identical binary must not collide (2026-07-09 #2)"
284 );
285 }
286
287 /// The floor is the highest across the bundle, not the first one found.
288 ///
289 /// A bundle is a primary binary plus companions, and the companion is
290 /// routinely built from different source than the server (mnw-cli ships in
291 /// the same promote). Taking the maximum is what makes the number a property
292 /// of the bundle rather than of whichever file the walk reached first.
293 #[tokio::test]
294 async fn the_floor_is_the_highest_across_every_binary_in_the_bundle() {
295 let dir = tempfile::tempdir().unwrap();
296 write(dir.path(), "assets/style.css", b"body{}").await;
297 write(
298 dir.path(),
299 "notes.txt",
300 b"GLIBC_9.99 as data, not a requirement",
301 )
302 .await;
303 let plain = digest_dir(dir.path()).await.unwrap();
304 assert_eq!(
305 plain.glibc_floor, None,
306 "a bundle of non-ELF files states no floor, and the literal string in \
307 notes.txt must not be mistaken for a requirement"
308 );
309
310 // The test binary is a real ELF built on this host. Placed twice, the
311 // floor must equal its own rather than doubling or resetting.
312 let Ok(exe) = std::fs::read(std::env::current_exe().unwrap()) else {
313 return;
314 };
315 let Some(own) = crate::elf::glibc_floor(&exe) else {
316 return;
317 };
318 write(dir.path(), "makenotwork", &exe).await;
319 write(dir.path(), "companions/mnw-cli", &exe).await;
320 let with_bins = digest_dir(dir.path()).await.unwrap();
321 assert_eq!(with_bins.glibc_floor, Some(own));
322 }
323
324 /// Reading the floor must not change what the bundle IS. The fixture test
325 /// above covers the manifest text; this covers the digest across a bundle
326 /// that actually contains an ELF.
327 #[tokio::test]
328 async fn computing_the_floor_does_not_disturb_the_digest() {
329 let dir = tempfile::tempdir().unwrap();
330 let exe = std::fs::read(std::env::current_exe().unwrap()).unwrap();
331 write(dir.path(), "bin", &exe).await;
332 write(dir.path(), "static/app.css", b"body{}").await;
333 let d = digest_dir(dir.path()).await.unwrap();
334 let expected_lines = 2;
335 assert_eq!(d.manifest.lines().count(), expected_lines);
336 // The digest is the hash of the manifest text and nothing else, so it
337 // must be reproducible from the manifest alone.
338 let rehash = hex(&Sha256::digest(d.manifest.as_bytes()));
339 assert_eq!(d.full, rehash);
340 }
341
342 #[tokio::test]
343 async fn manifest_file_is_excluded_from_its_own_digest() {
344 let dir = tempfile::tempdir().unwrap();
345 write(dir.path(), "makenotwork", b"bytes").await;
346 let before = super::digest_dir(dir.path()).await.unwrap();
347 // Write the manifest into the bundle, as Stage B will for node-side
348 // verification; the digest must not change.
349 write(dir.path(), MANIFEST_NAME, before.manifest.as_bytes()).await;
350 let after = super::digest_dir(dir.path()).await.unwrap();
351 assert_eq!(before.full, after.full);
352 }
353 }
354