Skip to main content

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}