yah_mesofact_bundle/runtime.rs
1//! The **node runtime-asset tier** (R746-F1, W272 §2/§7): the serve binary a
2//! *vanilla* bundle does not carry.
3//!
4//! A self-contained bundle (`runtime = "self"`) closes over its own
5//! `bins/<triple>/serve`, so its binary's bytes are covered by the bundle
6//! digest and resolution is a path join. A vanilla bundle
7//! (`runtime = "mesofact/<ver>"`) deliberately carries nothing — the whole
8//! point is that one ~70MB V8-carrying binary is a **node-level** asset shared
9//! by every site at that version, not a per-site blob re-uploaded and
10//! re-downloaded per deploy. That trade only works if the node can *get* the
11//! binary, which is what this module does. Until it existed, a vanilla bundle
12//! assembled fine and then had nothing to exec.
13//!
14//! ```text
15//! <cache>/bundles/<digest>/… # materialized bundle trees
16//! <cache>/runtimes/mesofact/0.8.20/<triple>/serve # stock runtime asset
17//! <cache>/runtimes/acme/site/1.2.3/<triple>/serve # namespaced CUSTOM runtime
18//! <cache>/runtimes/almanac-feed/0.8.22/<triple>/almanac-feed # a SIDECAR asset
19//! ```
20//!
21//! **The tier is not serve-only** (R746-T3). One runtime ref names one binary,
22//! whose filename the published manifest records — `serve` for a mesofact
23//! runtime, `almanac-feed` for the feed-fetch sidecar. Before this, the feed
24//! fetcher could only reach a node inside a self-contained bundle's `bins/`,
25//! which meant a *vanilla* bundle with a feed tier still needed a cross-built
26//! musl binary on the syncing machine. That single path was the last thing
27//! standing between yah.dev and "a templates-only edit deploys from a laptop
28//! with no Rust toolchain".
29//!
30//! **The path is namespaced from day one**, though nothing publishes a custom
31//! runtime yet (W272 §7): custom runtimes cache exactly like stock ones, and
32//! generalizing the key costs nothing while no node has written one to disk and
33//! costs a fleet-wide migration afterwards. Stock is the unnamespaced case —
34//! `mesofact/<ver>` is `<name>/<ver>`, `acme/site/1.2.3` is
35//! `<namespace>/<name>/<ver>`. See [`RuntimeRef`].
36//!
37//! **Trust comes from the content address, not the transport.** The by-name key
38//! (`runtimes/…/<triple>.toml`) resolves to a small published manifest naming
39//! the binary's blake3; the bytes themselves come from the same
40//! `blobs/<blake3>` namespace bundles use, and are verified against that hash
41//! before anything is written where a fork could reach it. This is the property
42//! the self-contained shape got for free from the bundle digest, and losing it
43//! would make vanilla a strictly worse trust posture than the shape it
44//! replaces. It also means the runtime asset dedupes against a self-contained
45//! bundle carrying the identical binary — same bytes, same blob.
46//!
47//! Sharing the bundle store rather than inventing a second one is deliberate:
48//! the node already reads it unauthenticated over plain HTTPS
49//! ([`yah_object_store::HttpReadOnlyObjectStore`]), so a runtime asset needs no
50//! new origin, no new credential, and no new cache-invalidation story.
51
52use std::fmt;
53use std::path::PathBuf;
54
55use crate::BundleError;
56
57/// A runtime the node resolves by *name*, as opposed to `runtime = "self"`
58/// which resolves inside the bundle.
59///
60/// Wire form is slash-separated, either `<name>/<version>` (stock, e.g.
61/// `mesofact/0.8.20`) or `<namespace>/<name>/<version>` (a custom runtime
62/// published under an org/project, e.g. `acme/site/1.2.3`). The namespace is
63/// what keeps two orgs' `site` runtimes from colliding in one node cache.
64///
65/// This parses the *general* form even though [`BundleRuntime::parse`] accepts
66/// only `mesofact/<ver>` today: the node cache layout is the thing that is
67/// expensive to change later (bytes on disk across a fleet), the manifest field
68/// is not. Accepting a namespaced ref here early is forward-compatible and
69/// unreachable in practice until a manifest can express one — R746-F6 versioned
70/// the contract such an expression would range over, but left the field itself
71/// a single runtime name (W272 §7).
72///
73/// [`BundleRuntime::parse`]: crate::BundleRuntime::parse
74#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
75pub struct RuntimeRef {
76 namespace: Option<String>,
77 name: String,
78 version: String,
79}
80
81impl RuntimeRef {
82 /// Parse the wire form (`<name>/<ver>` or `<ns>/<name>/<ver>`).
83 ///
84 /// Every segment is validated to `[A-Za-z0-9._+-]+` and rejected if it is
85 /// `.` or `..`. That is not decoration: these segments are joined onto a
86 /// node's cache directory, so a permissive parse would let a published
87 /// manifest name a write target outside the cache. Same reasoning as
88 /// `store::checked_rel` for bundle content paths.
89 pub fn parse(s: &str) -> Result<Self, BundleError> {
90 let segments: Vec<&str> = s.split('/').collect();
91 let (namespace, name, version) = match segments.as_slice() {
92 [name, version] => (None, *name, *version),
93 [ns, name, version] => (Some(*ns), *name, *version),
94 _ => {
95 return Err(BundleError::Runtime(format!(
96 "{s:?} is not a resolvable runtime reference (expected \
97 \"<name>/<version>\" or \"<namespace>/<name>/<version>\"; \
98 \"self\" is resolved inside the bundle, not here)"
99 )))
100 }
101 };
102 for seg in namespace.into_iter().chain([name, version]) {
103 check_segment(seg, s)?;
104 }
105 Ok(Self {
106 namespace: namespace.map(str::to_string),
107 name: name.to_string(),
108 version: version.to_string(),
109 })
110 }
111
112 /// Publishing namespace (`None` for a stock runtime).
113 pub fn namespace(&self) -> Option<&str> {
114 self.namespace.as_deref()
115 }
116
117 /// Runtime name, e.g. `mesofact`.
118 pub fn name(&self) -> &str {
119 &self.name
120 }
121
122 /// Exact runtime version, e.g. `0.8.20`.
123 pub fn version(&self) -> &str {
124 &self.version
125 }
126
127 /// The wire string this was parsed from.
128 pub fn as_wire(&self) -> String {
129 match &self.namespace {
130 Some(ns) => format!("{ns}/{}/{}", self.name, self.version),
131 None => format!("{}/{}", self.name, self.version),
132 }
133 }
134
135 /// Cache-relative path to the binary named `bin`:
136 /// `runtimes/[<ns>/]<name>/<ver>/<triple>/<bin>`.
137 ///
138 /// `bin` is a bare filename, never a path — it comes from a published
139 /// manifest, and joining an unchecked one onto the cache dir would be the
140 /// same write-outside-the-cache primitive [`RuntimeRef::parse`] exists to
141 /// deny. Callers pass a compile-time constant ([`SERVE_BIN`],
142 /// kamaji's `FEED_BIN_NAME`); [`check_bin_name`] enforces it for the
143 /// manifest-supplied side.
144 pub fn cache_rel_bin(&self, triple: &str, bin: &str) -> PathBuf {
145 self.cache_rel_dir(triple).join(bin)
146 }
147
148 /// Cache-relative directory holding this runtime's binaries for `triple`:
149 /// `runtimes/[<ns>/]<name>/<ver>/<triple>`.
150 pub fn cache_rel_dir(&self, triple: &str) -> PathBuf {
151 let mut p = PathBuf::from("runtimes");
152 if let Some(ns) = &self.namespace {
153 p.push(ns);
154 }
155 p.push(&self.name);
156 p.push(&self.version);
157 p.push(triple);
158 p
159 }
160
161 /// Cache-relative path to the serve binary:
162 /// `runtimes/[<ns>/]<name>/<ver>/<triple>/serve`. The [`SERVE_BIN`] case of
163 /// [`cache_rel_bin`](Self::cache_rel_bin).
164 pub fn cache_rel_serve(&self, triple: &str) -> PathBuf {
165 self.cache_rel_bin(triple, SERVE_BIN)
166 }
167
168 /// Object-store key *prefix* every published asset manifest for this
169 /// runtime sits directly under: `runtimes/[<ns>/]<name>/<ver>/`.
170 ///
171 /// The trailing slash is load-bearing — without it `mesofact/0.8.2` would
172 /// list `mesofact/0.8.20`'s assets too. Entries below it are
173 /// `<triple>.toml` and nothing else, so a listed key with a further `/` in
174 /// it belongs to some other ref and is not this runtime's.
175 pub fn asset_manifest_prefix(&self) -> String {
176 let mut key = String::from("runtimes/");
177 if let Some(ns) = &self.namespace {
178 key.push_str(ns);
179 key.push('/');
180 }
181 key.push_str(&self.name);
182 key.push('/');
183 key.push_str(&self.version);
184 key.push('/');
185 key
186 }
187
188 /// Object-store key of the published asset manifest for `triple`:
189 /// `runtimes/[<ns>/]<name>/<ver>/<triple>.toml`.
190 ///
191 /// By *name*, not by content — this is the one mutable-shaped key in the
192 /// scheme, and it is what turns "mesofact/0.8.20 on x86_64 musl" into a
193 /// blake3 the node can then verify bytes against.
194 pub fn asset_manifest_key(&self, triple: &str) -> String {
195 let mut key = String::from("runtimes/");
196 if let Some(ns) = &self.namespace {
197 key.push_str(ns);
198 key.push('/');
199 }
200 key.push_str(&self.name);
201 key.push('/');
202 key.push_str(&self.version);
203 key.push('/');
204 key.push_str(triple);
205 key.push_str(".toml");
206 key
207 }
208}
209
210impl fmt::Display for RuntimeRef {
211 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212 f.write_str(&self.as_wire())
213 }
214}
215
216/// Filename of a mesofact runtime's binary inside a resolved runtime-asset
217/// directory. Mirrors `bins/<triple>/serve` in a self-contained bundle so the
218/// two arms of resolution hand the same shape to the fork.
219pub const SERVE_BIN: &str = "serve";
220
221/// Reject a binary name that is anything other than a plain filename.
222///
223/// Applied to the *manifest-supplied* name in [`ensure_runtime_asset`]: a
224/// published manifest is remote input, and its `bin` field is joined onto the
225/// node's cache dir. Same reasoning — and the same character class — as
226/// [`RuntimeRef::parse`]'s segment check.
227pub fn check_bin_name(bin: &str) -> Result<(), BundleError> {
228 check_segment(bin, bin).map_err(|_| {
229 BundleError::Runtime(format!(
230 "runtime asset binary name {bin:?} is not a plain filename \
231 (must be non-empty [A-Za-z0-9._+-] and never \".\" or \"..\")"
232 ))
233 })
234}
235
236/// Reject anything that isn't a plain, path-safe identifier segment.
237fn check_segment(seg: &str, whole: &str) -> Result<(), BundleError> {
238 let ok = !seg.is_empty()
239 && seg != "."
240 && seg != ".."
241 && seg
242 .bytes()
243 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'+' | b'-'));
244 if ok {
245 return Ok(());
246 }
247 Err(BundleError::Runtime(format!(
248 "runtime reference {whole:?} has an unusable segment {seg:?} \
249 (segments must be non-empty [A-Za-z0-9._+-] and never \".\" or \"..\")"
250 )))
251}
252
253#[cfg(feature = "store")]
254pub use asset::{
255 ensure_runtime_asset, publish_runtime_asset, published_runtime_assets, require_runtime_contract,
256 PublishedRuntime, RuntimeAsset, RuntimeAssetManifest, RUNTIME_ASSET_SCHEMA_VERSION,
257};
258
259/// Runtime ref + binary name for the almanac feed-fetch sidecar (R746-T3).
260///
261/// The fetcher is versioned with yubaba, so it gets its own unnamespaced
262/// runtime ref (`almanac-feed/<ver>`) rather than riding the mesofact runtime's.
263pub const FEED_BIN: &str = "almanac-feed";
264
265#[cfg(feature = "store")]
266mod asset {
267 use std::fs;
268 use std::path::{Path, PathBuf};
269
270 use serde::{Deserialize, Serialize};
271 use yah_object_store::ObjectStore;
272
273 use super::{check_bin_name, RuntimeRef};
274 use crate::contract::{describe_contracts, ContractRequirement, ContractVersion};
275 use crate::store::{io_ctx, store_err, touch_access};
276 use crate::{blob_key, BundleError, BundleHash};
277
278 /// Wire version of the runtime-asset manifest. Separate from the bundle
279 /// manifest's [`SCHEMA_VERSION`](crate::SCHEMA_VERSION): they version
280 /// independent objects and a node speaking one may not speak the other.
281 pub const RUNTIME_ASSET_SCHEMA_VERSION: u32 = 1;
282
283 /// The small published object at `runtimes/…/<triple>.toml` — the by-name
284 /// pointer into the content-addressed blob space.
285 ///
286 /// ```toml
287 /// schema_version = 1
288 /// runtime = "mesofact/0.8.20"
289 /// triple = "x86_64-unknown-linux-musl"
290 /// bin = "serve" # filename the bytes land under in the node cache
291 /// contract = [1] # bundle↔runtime contract versions this binary implements
292 /// serve = "9f3c…" # blake3 of that binary, in blobs/<blake3>
293 /// ```
294 ///
295 /// It carries `runtime` and `triple` redundantly with its own key on
296 /// purpose: a node checks them against what it asked for, so an asset
297 /// misfiled under the wrong version (a publish-script bug, a copy-paste in
298 /// a pipeline) is caught by the fetcher instead of becoming a site served
299 /// by the wrong binary. `bin` is checked the same way, which is what makes
300 /// a sidecar asset (`almanac-feed`) safe to resolve through the same tier.
301 ///
302 /// **One ref, one binary.** The key is per (runtime × triple), so a runtime
303 /// that shipped two executables would need two refs — which is the right
304 /// shape anyway: `almanac-feed` is versioned with yubaba, not with
305 /// mesofact, and pretending otherwise would tie their release cadences
306 /// together for no reason.
307 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308 pub struct RuntimeAssetManifest {
309 /// Wire-format version. Always [`RUNTIME_ASSET_SCHEMA_VERSION`] today.
310 pub schema_version: u32,
311 /// The runtime this asset is for, in [`RuntimeRef`] wire form.
312 pub runtime: String,
313 /// Target triple these bytes were built for.
314 pub triple: String,
315 /// Filename these bytes are written under in the node's runtime cache,
316 /// and the name a resolver asks for. `serve` for a mesofact runtime.
317 ///
318 /// Defaulted rather than required so a manifest published before the
319 /// field existed still parses as the serve-only case it was.
320 #[serde(default = "default_bin")]
321 pub bin: String,
322 /// Bundle↔runtime contract versions this binary implements (R746-F6).
323 /// The **advertisement** half of the contract: a bundle requiring a
324 /// version absent from this list is refused rather than served.
325 ///
326 /// Defaulted to `[1]` for a manifest published before the field
327 /// existed — every such binary predates the second contract version by
328 /// construction, so claiming 1 is a statement of fact, not an
329 /// optimistic guess.
330 #[serde(default = "default_contract")]
331 pub contract: Vec<ContractVersion>,
332 /// BLAKE3 of the binary; its blob lives at `blobs/<serve>`.
333 pub serve: BundleHash,
334 }
335
336 fn default_bin() -> String {
337 super::SERVE_BIN.to_string()
338 }
339
340 fn default_contract() -> Vec<ContractVersion> {
341 vec![1]
342 }
343
344 impl RuntimeAssetManifest {
345 /// Parse a published asset manifest, rejecting an unknown schema version.
346 pub fn from_toml_str(s: &str) -> Result<Self, BundleError> {
347 let m: RuntimeAssetManifest =
348 toml::from_str(s).map_err(|e| BundleError::Manifest(e.to_string()))?;
349 if m.schema_version != RUNTIME_ASSET_SCHEMA_VERSION {
350 return Err(BundleError::SchemaVersion {
351 found: m.schema_version,
352 });
353 }
354 Ok(m)
355 }
356
357 /// Serialize to the published TOML form.
358 pub fn to_toml_string(&self) -> Result<String, BundleError> {
359 toml::to_string_pretty(self).map_err(|e| BundleError::Manifest(e.to_string()))
360 }
361 }
362
363 /// Outcome of [`publish_runtime_asset`].
364 #[derive(Debug, Clone)]
365 pub struct RuntimeAsset {
366 /// The published manifest.
367 pub manifest: RuntimeAssetManifest,
368 /// Key of the manifest object.
369 pub manifest_key: String,
370 /// Key of the binary blob.
371 pub blob_key: String,
372 /// False when the store already held the binary's bytes — the whole
373 /// point of sharing `blobs/` with bundles. A patch release that did not
374 /// change the serve binary uploads nothing.
375 pub blob_uploaded: bool,
376 }
377
378 /// Publish `serve_bin` as the runtime asset for `runtime` × `triple`,
379 /// landing on the node under the filename `bin`
380 /// ([`SERVE_BIN`](crate::SERVE_BIN) for a mesofact runtime;
381 /// `almanac-feed` for the feed-fetch sidecar).
382 ///
383 /// PUTs the binary at `blobs/<blake3>` (skipped when already present, the
384 /// same append-only dedupe `publish_bundle` uses) and the naming manifest at
385 /// `runtimes/…/<triple>.toml`. The manifest key is by-name and therefore
386 /// overwritable: re-publishing the same version with different bytes is a
387 /// *repoint*, which is exactly what a re-cut release is. Nodes that already
388 /// cached the old bytes keep them — the cache is keyed by name, so a repoint
389 /// only reaches nodes that had not fetched it yet. Publish under a new
390 /// version rather than repointing one that is live.
391 ///
392 /// `contract` is the set of bundle↔runtime contract versions **these bytes**
393 /// implement (R746-F6). It is a required parameter and not derived from
394 /// [`IMPLEMENTED_CONTRACTS`](crate::IMPLEMENTED_CONTRACTS) here on purpose:
395 /// this function publishes a binary that was almost always cross-built
396 /// somewhere else, so the publishing process's own constant is a statement
397 /// about the wrong tree. The CLI defaults the flag to that constant — which
398 /// is right when the binary came from this tree, and wrong silently
399 /// otherwise, so the honest place for the assumption is one layer up where
400 /// an operator can override it.
401 pub fn publish_runtime_asset(
402 store: &dyn ObjectStore,
403 runtime: &RuntimeRef,
404 triple: &str,
405 bin: &str,
406 contract: &[ContractVersion],
407 serve_bin: &Path,
408 ) -> Result<RuntimeAsset, BundleError> {
409 check_bin_name(bin)?;
410 if contract.is_empty() {
411 return Err(BundleError::Manifest(format!(
412 "refusing to publish {} as {runtime}/{triple} advertising no contract version at \
413 all — nothing could ever resolve it",
414 serve_bin.display()
415 )));
416 }
417 let bytes = fs::read(serve_bin).map_err(io_ctx(serve_bin))?;
418 let hash = BundleHash::of(&bytes);
419 let bkey = blob_key(&hash);
420 let blob_uploaded = if store.head(&bkey).map_err(store_err)? {
421 false
422 } else {
423 store.put(&bkey, bytes).map_err(store_err)?;
424 true
425 };
426
427 let manifest = RuntimeAssetManifest {
428 schema_version: RUNTIME_ASSET_SCHEMA_VERSION,
429 runtime: runtime.as_wire(),
430 triple: triple.to_string(),
431 bin: bin.to_string(),
432 contract: contract.to_vec(),
433 serve: hash,
434 };
435 let mkey = runtime.asset_manifest_key(triple);
436 store
437 .put(&mkey, manifest.to_toml_string()?.into_bytes())
438 .map_err(store_err)?;
439
440 Ok(RuntimeAsset {
441 manifest,
442 manifest_key: mkey,
443 blob_key: bkey,
444 blob_uploaded,
445 })
446 }
447
448 /// Resolve the binary named `bin` for `runtime` × `triple` under
449 /// `cache_dir`, fetching and verifying it from `store` on a miss. Returns
450 /// the executable path.
451 ///
452 /// `bin` is the caller's *expectation* — a compile-time constant at every
453 /// call site — and the published manifest's own `bin` is checked against
454 /// it, exactly as `runtime` and `triple` are. That is what keeps the
455 /// cache-hit fast path possible: the resolver knows the filename before it
456 /// has fetched anything, so a warm resolve touches the network zero times.
457 ///
458 /// Resolution is **purely** `<cache_dir>/runtimes/…/<bin>`: no `PATH`
459 /// lookup, no scan for a plausible binary elsewhere on the box, no reuse of
460 /// another bundle's `bins/`. A node that cannot fetch the named version
461 /// fails the deploy — serving a site with a binary nobody named is a worse
462 /// outcome than not serving it, because it succeeds silently.
463 ///
464 /// A cache hit returns immediately without re-hashing: the fetch verifies
465 /// before an atomic rename, so a file visible at the final path was already
466 /// checked, and re-blake3-ing ~70MB on every deploy buys nothing against a
467 /// threat model where an attacker who can write the cache dir can also
468 /// write the process that reads it.
469 ///
470 /// **Not evicted by [`BundleCache`](crate::BundleCache).** Runtime assets
471 /// live outside `bundles/`, so the bundle LRU neither counts nor reclaims
472 /// them — deliberately: one asset backs every resident serve process at that
473 /// version, and evicting it under a bundle-shaped budget would break the
474 /// next restart of sites that had nothing to do with the deploy that
475 /// tripped the budget. An access marker is touched on every resolve so a
476 /// future runtime-tier reclaim has recency to work from.
477 pub fn ensure_runtime_asset(
478 store: &dyn ObjectStore,
479 cache_dir: &Path,
480 runtime: &RuntimeRef,
481 triple: &str,
482 bin: &str,
483 requires: ContractRequirement,
484 ) -> Result<PathBuf, BundleError> {
485 check_bin_name(bin)?;
486 let dir = cache_dir.join(runtime.cache_rel_dir(triple));
487 let dest = dir.join(bin);
488 if dest.is_file() {
489 // A cache hit skips the contract check along with the re-hash.
490 // Checking it would cost a manifest GET on every warm resolve,
491 // which is precisely the network round-trip the shared runtime
492 // tier exists to avoid (see `a_second_resolve_at_the_same_version_
493 // is_a_cache_hit`), and it would buy nothing: the advertised set is
494 // a property of the ref, and the ref is the cache path.
495 //
496 // The one case it would catch is a live version REPOINTED to bytes
497 // advertising a different set — already documented as the thing not
498 // to do (`publish_runtime_asset`: publish a new version instead),
499 // and already able to serve stale bytes for reasons that have
500 // nothing to do with contracts.
501 let _ = touch_access(&dir);
502 return Ok(dest);
503 }
504
505 // By-name pointer → blake3.
506 let mkey = runtime.asset_manifest_key(triple);
507 let manifest_bytes = store
508 .get(&mkey)
509 .map_err(store_err)?
510 .ok_or_else(|| BundleError::RuntimeAssetMissing {
511 runtime: runtime.as_wire(),
512 triple: triple.to_string(),
513 location: store.locate(&mkey),
514 })?;
515 let manifest_text = String::from_utf8(manifest_bytes).map_err(|e| {
516 BundleError::Manifest(format!("runtime asset manifest {mkey} not utf-8: {e}"))
517 })?;
518 let manifest = RuntimeAssetManifest::from_toml_str(&manifest_text)?;
519 if manifest.runtime != runtime.as_wire() || manifest.triple != triple {
520 return Err(BundleError::Manifest(format!(
521 "runtime asset at {mkey} declares {}/{} but was published under {}/{triple} \
522 — refusing to serve a bundle with a binary built for something else",
523 manifest.runtime,
524 manifest.triple,
525 runtime.as_wire(),
526 )));
527 }
528 if manifest.bin != bin {
529 return Err(BundleError::Manifest(format!(
530 "runtime asset at {mkey} publishes the binary {:?} but {} was asked for \
531 {bin:?} — refusing to fork a binary under a name it was not published as",
532 manifest.bin,
533 runtime.as_wire(),
534 )));
535 }
536 // R746-F6: the contract check, and the last one before bytes are
537 // fetched. An apply-time gate is the primary enforcement point — this
538 // is the backstop for the paths that don't go through one (a node
539 // restarting a workload deployed before the gate existed, a hand-rolled
540 // deploy), and it fails before a ~70MB download rather than after.
541 if !requires.satisfied_by(&manifest.contract) {
542 let ContractRequirement::Version(required) = requires else {
543 unreachable!("Unchecked is satisfied by every advertised set")
544 };
545 return Err(BundleError::ContractUnsatisfied {
546 runtime: runtime.as_wire(),
547 triple: triple.to_string(),
548 required,
549 advertised: describe_contracts(&manifest.contract),
550 });
551 }
552
553 // Content address → bytes, verified before they land anywhere forkable.
554 let bkey = blob_key(&manifest.serve);
555 let bytes = store
556 .get(&bkey)
557 .map_err(store_err)?
558 .ok_or_else(|| BundleError::MissingBlob {
559 key: store.locate(&bkey),
560 path: format!("{}/{triple}/{bin}", runtime.as_wire()),
561 })?;
562 let actual = BundleHash::of(&bytes);
563 if actual != manifest.serve {
564 return Err(BundleError::HashMismatch {
565 what: format!("runtime asset {}/{triple}", runtime.as_wire()),
566 expected: manifest.serve.to_string(),
567 actual: actual.to_string(),
568 });
569 }
570
571 // Stage beside the destination and rename: the final path is never
572 // visible holding a partial or unverified binary, and two concurrent
573 // deploys of the same version race harmlessly (both wrote identical,
574 // verified bytes).
575 fs::create_dir_all(&dir).map_err(io_ctx(&dir))?;
576 let staging = dir.join(format!(".staging-{}", manifest.serve.as_str()));
577 fs::write(&staging, &bytes).map_err(io_ctx(&staging))?;
578 set_executable(&staging)?;
579 fs::rename(&staging, &dest).map_err(io_ctx(&dest))?;
580 let _ = touch_access(&dir);
581 Ok(dest)
582 }
583
584 /// One published runtime asset, as [`published_runtime_assets`] found it.
585 #[derive(Debug, Clone, PartialEq, Eq)]
586 pub struct PublishedRuntime {
587 /// Target triple the asset was published for.
588 pub triple: String,
589 /// Bundle↔runtime contract versions it advertises.
590 pub contract: Vec<ContractVersion>,
591 }
592
593 /// Every asset published for `runtime`, one per triple, sorted by triple.
594 ///
595 /// A manifest under the prefix that fails to parse is an error rather than
596 /// a skipped row: this backs an apply-time gate, and a gate that quietly
597 /// ignores the manifests it cannot read reports "all clear" for a store it
598 /// never actually surveyed.
599 pub fn published_runtime_assets(
600 store: &dyn ObjectStore,
601 runtime: &RuntimeRef,
602 ) -> Result<Vec<PublishedRuntime>, BundleError> {
603 let prefix = runtime.asset_manifest_prefix();
604 let mut out = Vec::new();
605 for key in store.list_prefix(&prefix).map_err(store_err)? {
606 let Some(rest) = key.strip_prefix(&prefix) else {
607 continue;
608 };
609 // Only this ref's own `<triple>.toml` rows — see asset_manifest_prefix.
610 let Some(triple) = rest.strip_suffix(".toml") else {
611 continue;
612 };
613 if triple.contains('/') || triple.is_empty() {
614 continue;
615 }
616 let bytes = store.get(&key).map_err(store_err)?.ok_or_else(|| {
617 // Listed and then gone: a concurrent delete, or a store whose
618 // listing lies. Either way this is not a runtime we can vouch for.
619 BundleError::Manifest(format!("runtime asset {key} was listed but could not be read"))
620 })?;
621 let text = String::from_utf8(bytes).map_err(|e| {
622 BundleError::Manifest(format!("runtime asset manifest {key} not utf-8: {e}"))
623 })?;
624 let manifest = RuntimeAssetManifest::from_toml_str(&text)?;
625 out.push(PublishedRuntime {
626 triple: triple.to_string(),
627 contract: manifest.contract,
628 });
629 }
630 out.sort_by(|a, b| a.triple.cmp(&b.triple));
631 Ok(out)
632 }
633
634 /// **The apply-time contract gate** (R746-F6). Refuse to deploy a bundle
635 /// requiring contract `required` when a published asset for `runtime`
636 /// cannot serve it, naming both versions.
637 ///
638 /// Returns the triples that *can*, so the caller can report what the deploy
639 /// is actually resolvable on.
640 ///
641 /// Two deliberate choices:
642 ///
643 /// * **An unsatisfied triple fails the whole apply**, even if the machines
644 /// this deploy targets run a different one. One runtime ref is one
645 /// release; a version published with mixed contract sets across triples
646 /// is a publishing bug, and letting it through means the next site
647 /// placed on the odd node out fails on that node instead — at serve
648 /// time, on a machine nobody is watching, which is the exact failure this
649 /// gate exists to move earlier.
650 /// * **Nothing published at all is `Ok(vec![])`, not an error.** That case
651 /// is [`BundleError::RuntimeAssetMissing`]'s (R746-F1) and it already
652 /// names the version and the location it looked; re-reporting it here as
653 /// a contract failure would send an operator hunting for a mismatch that
654 /// doesn't exist. The caller decides how loud an empty survey is.
655 pub fn require_runtime_contract(
656 store: &dyn ObjectStore,
657 runtime: &RuntimeRef,
658 required: ContractVersion,
659 ) -> Result<Vec<String>, BundleError> {
660 let published = published_runtime_assets(store, runtime)?;
661 let requirement = ContractRequirement::Version(required);
662 for asset in &published {
663 if !requirement.satisfied_by(&asset.contract) {
664 return Err(BundleError::ContractUnsatisfied {
665 runtime: runtime.as_wire(),
666 triple: asset.triple.clone(),
667 required,
668 advertised: describe_contracts(&asset.contract),
669 });
670 }
671 }
672 Ok(published.into_iter().map(|a| a.triple).collect())
673 }
674
675 /// Mark a staged runtime binary executable before it becomes visible.
676 #[cfg(unix)]
677 fn set_executable(path: &Path) -> Result<(), BundleError> {
678 use std::os::unix::fs::PermissionsExt;
679 let mut perms = fs::metadata(path).map_err(io_ctx(path))?.permissions();
680 perms.set_mode(0o755);
681 fs::set_permissions(path, perms).map_err(io_ctx(path))
682 }
683
684 #[cfg(not(unix))]
685 fn set_executable(_path: &Path) -> Result<(), BundleError> {
686 Ok(())
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693
694 #[test]
695 fn stock_is_the_unnamespaced_case_and_custom_is_namespaced() {
696 let stock = RuntimeRef::parse("mesofact/0.8.20").unwrap();
697 assert_eq!(stock.namespace(), None);
698 assert_eq!(stock.name(), "mesofact");
699 assert_eq!(stock.version(), "0.8.20");
700 assert_eq!(stock.as_wire(), "mesofact/0.8.20");
701
702 let custom = RuntimeRef::parse("acme/site/1.2.3").unwrap();
703 assert_eq!(custom.namespace(), Some("acme"));
704 assert_eq!(custom.name(), "site");
705 assert_eq!(custom.version(), "1.2.3");
706 assert_eq!(custom.as_wire(), "acme/site/1.2.3");
707 }
708
709 /// The layout this ticket commits a fleet to. Changing it after nodes have
710 /// written to disk is a migration, so pin both arms.
711 #[test]
712 fn cache_layout_is_namespace_name_version_triple() {
713 let triple = "x86_64-unknown-linux-musl";
714 assert_eq!(
715 RuntimeRef::parse("mesofact/0.8.20")
716 .unwrap()
717 .cache_rel_serve(triple),
718 PathBuf::from("runtimes/mesofact/0.8.20/x86_64-unknown-linux-musl/serve")
719 );
720 assert_eq!(
721 RuntimeRef::parse("acme/site/1.2.3")
722 .unwrap()
723 .cache_rel_serve(triple),
724 PathBuf::from("runtimes/acme/site/1.2.3/x86_64-unknown-linux-musl/serve")
725 );
726 assert_eq!(
727 RuntimeRef::parse("mesofact/0.8.20")
728 .unwrap()
729 .asset_manifest_key(triple),
730 "runtimes/mesofact/0.8.20/x86_64-unknown-linux-musl.toml"
731 );
732 }
733
734 /// These segments are joined onto a node's cache dir, so a permissive parse
735 /// is a write-outside-the-cache primitive handed to whoever publishes a
736 /// manifest.
737 #[test]
738 fn traversal_and_junk_segments_are_rejected() {
739 assert!(RuntimeRef::parse("mesofact/../../etc").is_err());
740 assert!(RuntimeRef::parse("../mesofact/0.1.0").is_err());
741 assert!(RuntimeRef::parse("mesofact/.").is_err());
742 assert!(RuntimeRef::parse("mesofact/").is_err());
743 assert!(RuntimeRef::parse("/0.1.0").is_err());
744 assert!(RuntimeRef::parse("a/b/c/d").is_err());
745 assert!(RuntimeRef::parse("mesofact").is_err());
746 // `self` is resolved inside the bundle and must never reach this path.
747 assert!(RuntimeRef::parse("self").is_err());
748 // A whitespace or shell-metacharacter segment can't become a path.
749 assert!(RuntimeRef::parse("mesofact/0.8 20").is_err());
750 assert!(RuntimeRef::parse("mesofact/$(id)").is_err());
751 }
752
753 #[cfg(feature = "store")]
754 mod asset {
755 use super::*;
756 use crate::runtime::{
757 ensure_runtime_asset, publish_runtime_asset, published_runtime_assets,
758 require_runtime_contract, PublishedRuntime, RuntimeAssetManifest,
759 };
760 use crate::{
761 blob_key, BundleError, BundleHash, ContractRequirement, ContractVersion,
762 BUNDLE_CONTRACT_VERSION, IMPLEMENTED_CONTRACTS,
763 };
764 use tempfile::TempDir;
765 use yah_object_store::{InMemoryObjectStore, ObjectStore};
766
767 const TRIPLE: &str = "x86_64-unknown-linux-musl";
768 /// What a stock runtime built from this tree advertises.
769 const V1: &[ContractVersion] = IMPLEMENTED_CONTRACTS;
770 /// What a bundle assembled by this tree requires.
771 const REQ1: ContractRequirement = ContractRequirement::Version(BUNDLE_CONTRACT_VERSION);
772
773 fn write_bin(dir: &TempDir, bytes: &[u8]) -> std::path::PathBuf {
774 let p = dir.path().join("mesofact-serve");
775 std::fs::write(&p, bytes).unwrap();
776 p
777 }
778
779 #[test]
780 fn publish_then_resolve_round_trips_and_lands_executable() {
781 let store = InMemoryObjectStore::new();
782 let src = TempDir::new().unwrap();
783 let cache = TempDir::new().unwrap();
784 let rref = RuntimeRef::parse("mesofact/0.8.20").unwrap();
785
786 let published =
787 publish_runtime_asset(&store, &rref, TRIPLE, SERVE_BIN, V1, &write_bin(&src, b"ELF serve")).unwrap();
788 assert!(published.blob_uploaded);
789
790 let resolved = ensure_runtime_asset(&store, cache.path(), &rref, TRIPLE, SERVE_BIN, REQ1).unwrap();
791 assert_eq!(
792 resolved,
793 cache.path().join("runtimes/mesofact/0.8.20").join(TRIPLE).join("serve")
794 );
795 assert_eq!(std::fs::read(&resolved).unwrap(), b"ELF serve");
796 #[cfg(unix)]
797 {
798 use std::os::unix::fs::PermissionsExt;
799 let mode = std::fs::metadata(&resolved).unwrap().permissions().mode();
800 assert!(mode & 0o111 != 0, "a runtime asset must land forkable, mode={mode:o}");
801 }
802 }
803
804 /// The sharing property the vanilla shape exists for: a second site at
805 /// the same runtime version fetches nothing. A per-bundle copy would be
806 /// the self-contained shape wearing a different manifest.
807 #[test]
808 fn a_second_resolve_at_the_same_version_is_a_cache_hit() {
809 struct CountingStore {
810 inner: InMemoryObjectStore,
811 gets: std::sync::atomic::AtomicUsize,
812 }
813 impl ObjectStore for CountingStore {
814 fn put(&self, key: &str, data: Vec<u8>) -> Result<(), yah_object_store::Error> {
815 self.inner.put(key, data)
816 }
817 fn get(&self, key: &str) -> Result<Option<Vec<u8>>, yah_object_store::Error> {
818 self.gets.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
819 self.inner.get(key)
820 }
821 fn delete(&self, key: &str) -> Result<(), yah_object_store::Error> {
822 self.inner.delete(key)
823 }
824 fn list_prefix(&self, p: &str) -> Result<Vec<String>, yah_object_store::Error> {
825 self.inner.list_prefix(p)
826 }
827 }
828
829 let store = CountingStore {
830 inner: InMemoryObjectStore::new(),
831 gets: std::sync::atomic::AtomicUsize::new(0),
832 };
833 let src = TempDir::new().unwrap();
834 let cache = TempDir::new().unwrap();
835 let rref = RuntimeRef::parse("mesofact/0.8.20").unwrap();
836 publish_runtime_asset(&store, &rref, TRIPLE, SERVE_BIN, V1, &write_bin(&src, b"ELF serve")).unwrap();
837
838 ensure_runtime_asset(&store, cache.path(), &rref, TRIPLE, SERVE_BIN, REQ1).unwrap();
839 let after_cold = store.gets.load(std::sync::atomic::Ordering::SeqCst);
840 assert!(after_cold >= 2, "cold resolve fetches manifest + blob");
841
842 // Second site, same node, same runtime version.
843 ensure_runtime_asset(&store, cache.path(), &rref, TRIPLE, SERVE_BIN, REQ1).unwrap();
844 assert_eq!(
845 store.gets.load(std::sync::atomic::Ordering::SeqCst),
846 after_cold,
847 "a second bundle at the same runtime version must fetch nothing"
848 );
849 }
850
851 /// Two versions are two assets; resolving one must not satisfy the other.
852 #[test]
853 fn versions_do_not_alias() {
854 let store = InMemoryObjectStore::new();
855 let src = TempDir::new().unwrap();
856 let cache = TempDir::new().unwrap();
857 let old = RuntimeRef::parse("mesofact/0.8.19").unwrap();
858 let new = RuntimeRef::parse("mesofact/0.8.20").unwrap();
859 publish_runtime_asset(&store, &old, TRIPLE, SERVE_BIN, V1, &write_bin(&src, b"old serve")).unwrap();
860 ensure_runtime_asset(&store, cache.path(), &old, TRIPLE, SERVE_BIN, REQ1).unwrap();
861
862 // 0.8.20 was never published — the cached 0.8.19 must not stand in.
863 let err = ensure_runtime_asset(&store, cache.path(), &new, TRIPLE, SERVE_BIN, REQ1).unwrap_err();
864 assert!(
865 matches!(err, BundleError::RuntimeAssetMissing { .. }),
866 "got {err:?}"
867 );
868 }
869
870 /// Verify #2: an unfetchable version fails loudly, naming the version
871 /// and where it looked, and falls back to nothing.
872 #[test]
873 fn an_unpublished_version_fails_naming_version_and_location() {
874 let store = InMemoryObjectStore::new();
875 let cache = TempDir::new().unwrap();
876 let rref = RuntimeRef::parse("mesofact/9.9.9").unwrap();
877
878 let err = ensure_runtime_asset(&store, cache.path(), &rref, TRIPLE, SERVE_BIN, REQ1).unwrap_err();
879 let msg = err.to_string();
880 assert!(msg.contains("mesofact/9.9.9"), "got {msg}");
881 assert!(msg.contains(TRIPLE), "got {msg}");
882 assert!(
883 msg.contains("runtimes/mesofact/9.9.9/x86_64-unknown-linux-musl.toml"),
884 "the message must name what it looked for, got {msg}"
885 );
886 assert!(
887 !cache.path().join("runtimes").exists(),
888 "a failed resolve must not leave a partial runtime dir"
889 );
890 }
891
892 /// The trust property vanilla must not lose relative to a self-contained
893 /// bundle, whose binary is covered by the bundle digest.
894 #[test]
895 fn tampered_bytes_are_refused_and_never_written() {
896 let store = InMemoryObjectStore::new();
897 let src = TempDir::new().unwrap();
898 let cache = TempDir::new().unwrap();
899 let rref = RuntimeRef::parse("mesofact/0.8.20").unwrap();
900 let published =
901 publish_runtime_asset(&store, &rref, TRIPLE, SERVE_BIN, V1, &write_bin(&src, b"ELF serve")).unwrap();
902
903 // A hostile origin swaps the blob's bytes under its content address.
904 store.put(&published.blob_key, b"malware".to_vec()).unwrap();
905
906 let err = ensure_runtime_asset(&store, cache.path(), &rref, TRIPLE, SERVE_BIN, REQ1).unwrap_err();
907 assert!(matches!(err, BundleError::HashMismatch { .. }), "got {err:?}");
908 assert!(
909 !cache.path().join(rref.cache_rel_serve(TRIPLE)).exists(),
910 "unverified bytes must never reach the resolved path"
911 );
912 }
913
914 /// An asset misfiled under the wrong version is caught by the fetcher
915 /// rather than becoming a site served by the wrong binary.
916 #[test]
917 fn a_misfiled_asset_manifest_is_refused() {
918 let store = InMemoryObjectStore::new();
919 let cache = TempDir::new().unwrap();
920 let asked = RuntimeRef::parse("mesofact/0.8.20").unwrap();
921
922 // Published at 0.8.20's key but declaring 0.8.19 inside.
923 let bytes = b"ELF serve".to_vec();
924 let hash = BundleHash::of(&bytes);
925 store.put(&blob_key(&hash), bytes).unwrap();
926 let manifest = RuntimeAssetManifest {
927 schema_version: 1,
928 runtime: "mesofact/0.8.19".to_string(),
929 triple: TRIPLE.to_string(),
930 bin: SERVE_BIN.to_string(),
931 contract: V1.to_vec(),
932 serve: hash,
933 };
934 store
935 .put(
936 &asked.asset_manifest_key(TRIPLE),
937 manifest.to_toml_string().unwrap().into_bytes(),
938 )
939 .unwrap();
940
941 let err = ensure_runtime_asset(&store, cache.path(), &asked, TRIPLE, SERVE_BIN, REQ1).unwrap_err();
942 assert!(err.to_string().contains("0.8.19"), "got {err}");
943 }
944
945 /// A runtime asset and a self-contained bundle carrying the identical
946 /// binary share one blob — the dedupe that makes sharing the bundle
947 /// store rather than inventing a second one worth it.
948 #[test]
949 fn identical_binaries_dedupe_against_the_bundle_blob_space() {
950 let store = InMemoryObjectStore::new();
951 let src = TempDir::new().unwrap();
952 let rref = RuntimeRef::parse("mesofact/0.8.20").unwrap();
953 let bin = write_bin(&src, b"ELF serve");
954
955 let first = publish_runtime_asset(&store, &rref, TRIPLE, SERVE_BIN, V1, &bin).unwrap();
956 assert!(first.blob_uploaded);
957 let other = RuntimeRef::parse("acme/site/1.0.0").unwrap();
958 let second = publish_runtime_asset(&store, &other, TRIPLE, SERVE_BIN, V1, &bin).unwrap();
959 assert!(!second.blob_uploaded, "identical bytes must not re-upload");
960 assert_eq!(first.blob_key, second.blob_key);
961 }
962
963 /// R746-T3: the tier is not serve-only. The feed fetcher resolves
964 /// through it under its own name, which is what lets a *vanilla*
965 /// bundle carry a feed tier without carrying a binary.
966 #[test]
967 fn a_sidecar_asset_resolves_under_its_own_binary_name() {
968 let store = InMemoryObjectStore::new();
969 let src = TempDir::new().unwrap();
970 let cache = TempDir::new().unwrap();
971 let feed = RuntimeRef::parse("almanac-feed/0.8.22").unwrap();
972
973 publish_runtime_asset(&store, &feed, TRIPLE, FEED_BIN, V1, &write_bin(&src, b"ELF feed"))
974 .unwrap();
975 let resolved = ensure_runtime_asset(&store, cache.path(), &feed, TRIPLE, FEED_BIN, REQ1)
976 .unwrap();
977
978 assert_eq!(
979 resolved,
980 cache
981 .path()
982 .join("runtimes/almanac-feed/0.8.22")
983 .join(TRIPLE)
984 .join(FEED_BIN)
985 );
986 assert_eq!(std::fs::read(&resolved).unwrap(), b"ELF feed");
987 #[cfg(unix)]
988 {
989 use std::os::unix::fs::PermissionsExt;
990 let mode = std::fs::metadata(&resolved).unwrap().permissions().mode();
991 assert!(mode & 0o111 != 0, "a sidecar asset must land forkable");
992 }
993 }
994
995 /// The binary name is checked like `runtime` and `triple` are: asking
996 /// for `serve` and being handed the feed fetcher is exactly the
997 /// misfiling the by-name key makes possible, and it would fork a
998 /// binary that exits on `serve --bundle`.
999 #[test]
1000 fn an_asset_published_under_another_binary_name_is_refused() {
1001 let store = InMemoryObjectStore::new();
1002 let src = TempDir::new().unwrap();
1003 let cache = TempDir::new().unwrap();
1004 let rref = RuntimeRef::parse("mesofact/0.8.22").unwrap();
1005
1006 publish_runtime_asset(&store, &rref, TRIPLE, FEED_BIN, V1, &write_bin(&src, b"ELF feed"))
1007 .unwrap();
1008
1009 let err =
1010 ensure_runtime_asset(&store, cache.path(), &rref, TRIPLE, SERVE_BIN, REQ1).unwrap_err();
1011 let msg = err.to_string();
1012 assert!(msg.contains(FEED_BIN) && msg.contains(SERVE_BIN), "got {msg}");
1013 assert!(
1014 !cache.path().join(rref.cache_rel_serve(TRIPLE)).exists(),
1015 "a refused resolve must leave nothing forkable behind"
1016 );
1017 }
1018
1019 /// Two assets at one version × triple do not collide, because they are
1020 /// two refs — and the cache dirs are disjoint, so the mesofact runtime
1021 /// never shadows the fetcher.
1022 #[test]
1023 fn serve_and_feed_assets_do_not_share_a_cache_slot() {
1024 let store = InMemoryObjectStore::new();
1025 let src = TempDir::new().unwrap();
1026 let cache = TempDir::new().unwrap();
1027 let serve_ref = RuntimeRef::parse("mesofact/0.8.22").unwrap();
1028 let feed_ref = RuntimeRef::parse("almanac-feed/0.8.22").unwrap();
1029
1030 publish_runtime_asset(&store, &serve_ref, TRIPLE, SERVE_BIN, V1, &write_bin(&src, b"S"))
1031 .unwrap();
1032 let feed_src = TempDir::new().unwrap();
1033 publish_runtime_asset(&store, &feed_ref, TRIPLE, FEED_BIN, V1, &write_bin(&feed_src, b"F"))
1034 .unwrap();
1035
1036 let s = ensure_runtime_asset(&store, cache.path(), &serve_ref, TRIPLE, SERVE_BIN, REQ1)
1037 .unwrap();
1038 let f =
1039 ensure_runtime_asset(&store, cache.path(), &feed_ref, TRIPLE, FEED_BIN, REQ1).unwrap();
1040 assert_ne!(s, f);
1041 assert_eq!(std::fs::read(&s).unwrap(), b"S");
1042 assert_eq!(std::fs::read(&f).unwrap(), b"F");
1043 }
1044
1045 /// A published manifest is remote input; its `bin` is joined onto the
1046 /// node's cache dir. Same traversal posture as the ref's segments.
1047 #[test]
1048 fn a_traversing_binary_name_is_refused() {
1049 for bad in ["../serve", "a/b", "..", ".", "", "serve bin"] {
1050 assert!(
1051 crate::runtime::check_bin_name(bad).is_err(),
1052 "{bad:?} must not become a cache path"
1053 );
1054 }
1055 assert!(crate::runtime::check_bin_name(FEED_BIN).is_ok());
1056 assert!(crate::runtime::check_bin_name(SERVE_BIN).is_ok());
1057 }
1058
1059 // ---- R746-F6: the bundle↔runtime contract ----
1060
1061 /// The node-side backstop. A runtime that does not advertise the
1062 /// contract a bundle requires is refused *before* its bytes are
1063 /// fetched, and the message names both versions — the fix is a choice
1064 /// between them and an operator cannot make it holding only one.
1065 #[test]
1066 fn a_runtime_that_does_not_implement_the_required_contract_is_refused() {
1067 let store = InMemoryObjectStore::new();
1068 let src = TempDir::new().unwrap();
1069 let cache = TempDir::new().unwrap();
1070 let rref = RuntimeRef::parse("mesofact/0.8.20").unwrap();
1071
1072 // Published advertising contract 1 only.
1073 publish_runtime_asset(&store, &rref, TRIPLE, SERVE_BIN, &[1], &write_bin(&src, b"ELF"))
1074 .unwrap();
1075
1076 let err = ensure_runtime_asset(
1077 &store,
1078 cache.path(),
1079 &rref,
1080 TRIPLE,
1081 SERVE_BIN,
1082 ContractRequirement::Version(2),
1083 )
1084 .unwrap_err();
1085
1086 let msg = err.to_string();
1087 assert!(matches!(err, BundleError::ContractUnsatisfied { .. }), "got {err:?}");
1088 assert!(msg.contains("mesofact/0.8.20") && msg.contains(TRIPLE), "got {msg}");
1089 assert!(msg.contains('1') && msg.contains('2'), "both versions, got {msg}");
1090 assert!(
1091 !cache.path().join(rref.cache_rel_serve(TRIPLE)).exists(),
1092 "a refused resolve must leave nothing forkable behind"
1093 );
1094 }
1095
1096 /// A runtime serving several contracts satisfies a bundle requiring any
1097 /// of them — the property that makes a published bundle keep working
1098 /// across a contract bump instead of being stranded by it.
1099 #[test]
1100 fn a_runtime_advertising_a_set_serves_every_version_in_it() {
1101 let store = InMemoryObjectStore::new();
1102 let src = TempDir::new().unwrap();
1103 let rref = RuntimeRef::parse("mesofact/0.9.0").unwrap();
1104 publish_runtime_asset(&store, &rref, TRIPLE, SERVE_BIN, &[1, 2], &write_bin(&src, b"ELF"))
1105 .unwrap();
1106
1107 for required in [1, 2] {
1108 let cache = TempDir::new().unwrap();
1109 assert!(
1110 ensure_runtime_asset(
1111 &store,
1112 cache.path(),
1113 &rref,
1114 TRIPLE,
1115 SERVE_BIN,
1116 ContractRequirement::Version(required),
1117 )
1118 .is_ok(),
1119 "contract {required} is advertised and must resolve"
1120 );
1121 }
1122 }
1123
1124 /// The sidecar exemption is real and narrow: `almanac-feed`'s interface
1125 /// is its own CLI, not the bundle↔runtime contract, so it resolves
1126 /// `Unchecked` against whatever its publisher recorded.
1127 #[test]
1128 fn an_unchecked_requirement_resolves_a_sidecar_whatever_it_advertises() {
1129 let store = InMemoryObjectStore::new();
1130 let src = TempDir::new().unwrap();
1131 let cache = TempDir::new().unwrap();
1132 let feed = RuntimeRef::parse("almanac-feed/0.8.22").unwrap();
1133 publish_runtime_asset(&store, &feed, TRIPLE, FEED_BIN, &[99], &write_bin(&src, b"F"))
1134 .unwrap();
1135
1136 assert!(ensure_runtime_asset(
1137 &store,
1138 cache.path(),
1139 &feed,
1140 TRIPLE,
1141 FEED_BIN,
1142 ContractRequirement::Unchecked,
1143 )
1144 .is_ok());
1145 }
1146
1147 /// A manifest published before the field existed advertises contract 1
1148 /// — a statement of fact about every binary that predates version 2,
1149 /// not an optimistic default. Without it, every asset already in R2
1150 /// would stop resolving the moment this shipped.
1151 #[test]
1152 fn an_asset_manifest_without_a_contract_field_advertises_contract_1() {
1153 let store = InMemoryObjectStore::new();
1154 let cache = TempDir::new().unwrap();
1155 let rref = RuntimeRef::parse("mesofact/0.8.18").unwrap();
1156
1157 // Hand-written in the pre-F6 shape.
1158 let bytes = b"ELF serve".to_vec();
1159 let hash = BundleHash::of(&bytes);
1160 store.put(&blob_key(&hash), bytes).unwrap();
1161 store
1162 .put(
1163 &rref.asset_manifest_key(TRIPLE),
1164 format!(
1165 "schema_version = 1\nruntime = \"mesofact/0.8.18\"\n\
1166 triple = \"{TRIPLE}\"\nbin = \"serve\"\nserve = \"{hash}\"\n"
1167 )
1168 .into_bytes(),
1169 )
1170 .unwrap();
1171
1172 let parsed = RuntimeAssetManifest::from_toml_str(
1173 &String::from_utf8(store.get(&rref.asset_manifest_key(TRIPLE)).unwrap().unwrap())
1174 .unwrap(),
1175 )
1176 .unwrap();
1177 assert_eq!(parsed.contract, vec![1]);
1178
1179 assert!(
1180 ensure_runtime_asset(&store, cache.path(), &rref, TRIPLE, SERVE_BIN, REQ1).is_ok(),
1181 "a pre-F6 asset must keep serving a contract-1 bundle"
1182 );
1183 }
1184
1185 /// An asset advertising nothing could never be resolved by any bundle,
1186 /// so it is refused at publish rather than becoming a runtime that
1187 /// silently satisfies no one.
1188 #[test]
1189 fn publishing_an_asset_that_advertises_no_contract_is_refused() {
1190 let store = InMemoryObjectStore::new();
1191 let src = TempDir::new().unwrap();
1192 let rref = RuntimeRef::parse("mesofact/0.8.20").unwrap();
1193 let err =
1194 publish_runtime_asset(&store, &rref, TRIPLE, SERVE_BIN, &[], &write_bin(&src, b"E"))
1195 .unwrap_err();
1196 assert!(err.to_string().contains("no contract version"), "got {err}");
1197 assert!(
1198 store.list_prefix(&rref.asset_manifest_prefix()).unwrap().is_empty(),
1199 "a refused publish must write no manifest"
1200 );
1201 }
1202
1203 /// The apply-time gate's survey: one row per published triple, sorted,
1204 /// carrying what each advertises.
1205 #[test]
1206 fn the_survey_reports_every_published_triple_for_a_runtime() {
1207 let store = InMemoryObjectStore::new();
1208 let src = TempDir::new().unwrap();
1209 let rref = RuntimeRef::parse("mesofact/0.8.20").unwrap();
1210 publish_runtime_asset(&store, &rref, "x86_64-unknown-linux-musl", SERVE_BIN, &[1], &write_bin(&src, b"x")).unwrap();
1211 let src2 = TempDir::new().unwrap();
1212 publish_runtime_asset(&store, &rref, "aarch64-unknown-linux-musl", SERVE_BIN, &[1, 2], &write_bin(&src2, b"a")).unwrap();
1213 // A neighbouring version must not leak into this one's survey.
1214 let other = RuntimeRef::parse("mesofact/0.8.2").unwrap();
1215 let src3 = TempDir::new().unwrap();
1216 publish_runtime_asset(&store, &other, "x86_64-unknown-linux-musl", SERVE_BIN, &[9], &write_bin(&src3, b"o")).unwrap();
1217
1218 assert_eq!(
1219 published_runtime_assets(&store, &rref).unwrap(),
1220 vec![
1221 PublishedRuntime {
1222 triple: "aarch64-unknown-linux-musl".to_string(),
1223 contract: vec![1, 2],
1224 },
1225 PublishedRuntime {
1226 triple: "x86_64-unknown-linux-musl".to_string(),
1227 contract: vec![1],
1228 },
1229 ]
1230 );
1231 }
1232
1233 /// **Verify #1.** The apply-time gate refuses before anything reaches a
1234 /// node, naming the runtime, the triple, and both contract versions.
1235 #[test]
1236 fn the_apply_gate_refuses_a_runtime_that_cannot_serve_the_bundle() {
1237 let store = InMemoryObjectStore::new();
1238 let src = TempDir::new().unwrap();
1239 let rref = RuntimeRef::parse("mesofact/0.8.20").unwrap();
1240 publish_runtime_asset(&store, &rref, TRIPLE, SERVE_BIN, &[1], &write_bin(&src, b"x"))
1241 .unwrap();
1242
1243 assert_eq!(
1244 require_runtime_contract(&store, &rref, 1).unwrap(),
1245 vec![TRIPLE.to_string()]
1246 );
1247
1248 let err = require_runtime_contract(&store, &rref, 2).unwrap_err();
1249 let msg = err.to_string();
1250 assert!(msg.contains("mesofact/0.8.20"), "got {msg}");
1251 assert!(msg.contains(TRIPLE), "got {msg}");
1252 assert!(msg.contains('1') && msg.contains('2'), "both versions, got {msg}");
1253 }
1254
1255 /// One unsatisfied triple fails the whole apply even when others are
1256 /// fine — a version published with mixed contract sets is a publishing
1257 /// bug, and letting it through relocates the failure onto whichever
1258 /// node happens to be the odd one out, at serve time.
1259 #[test]
1260 fn one_stale_triple_fails_the_apply_for_all_of_them() {
1261 let store = InMemoryObjectStore::new();
1262 let rref = RuntimeRef::parse("mesofact/0.9.0").unwrap();
1263 let a = TempDir::new().unwrap();
1264 let b = TempDir::new().unwrap();
1265 publish_runtime_asset(&store, &rref, "x86_64-unknown-linux-musl", SERVE_BIN, &[1, 2], &write_bin(&a, b"new")).unwrap();
1266 publish_runtime_asset(&store, &rref, "aarch64-unknown-linux-musl", SERVE_BIN, &[1], &write_bin(&b, b"old")).unwrap();
1267
1268 let err = require_runtime_contract(&store, &rref, 2).unwrap_err();
1269 assert!(
1270 err.to_string().contains("aarch64-unknown-linux-musl"),
1271 "the refusal must name the triple that is behind, got {err}"
1272 );
1273 }
1274
1275 /// Nothing published is not a contract failure — that is
1276 /// `RuntimeAssetMissing`'s case (R746-F1), which already names the
1277 /// version and where it looked. Reporting it as a mismatch would send
1278 /// an operator hunting for a version conflict that does not exist.
1279 #[test]
1280 fn an_unpublished_runtime_surveys_empty_rather_than_failing_the_gate() {
1281 let store = InMemoryObjectStore::new();
1282 let rref = RuntimeRef::parse("mesofact/9.9.9").unwrap();
1283 assert!(require_runtime_contract(&store, &rref, 1).unwrap().is_empty());
1284 }
1285 }
1286}