Skip to main content

varve_core/
compose.rs

1//! Layer composition (REQ-COMPOSE-001).
2//!
3//! A pin names one realm and one layer. That was fine while a layer held one
4//! organisation's tools, and it broke the first time a consumer needed two: the
5//! PulseEngine tools that CHECK their work and the upstream tools that BUILD it
6//! (varve#52). Putting both in one layer would place releases we do not control
7//! under a qualification claim covering tools we do — so instead a layer may
8//! COMPOSE another.
9//!
10//! An include is a manifest entry of payload kind `layer`, whose digest is the
11//! included layer's manifest digest and whose annotations name its realm. It
12//! lives in the signed payload, so the composition is signed; and because the
13//! digest is the identity, an include cannot silently drift.
14//!
15//! Everything here fails closed. A cycle is refused rather than followed, depth
16//! is bounded, and a tool exposed by two layers is an ERROR naming both — varve
17//! does not pick a winner, for the same reason a pin that does not resolve
18//! uniquely is an error and not a fallback.
19//!
20//! What v0.29.0 adds (REQ-REALM2-001 clause 4) is the way THROUGH that refusal.
21//! Two realms shipping one name stopped being hypothetical the moment a fork
22//! existed beside its upstream — which is the whole reason the fork exists,
23//! upstream not attesting every tool. The escape is a realm QUALIFIER in the
24//! pin, decided here and nowhere else: `select_tools` is the single place that
25//! says what a bare name dispatches to, it consults only the pin's choice, and
26//! it never consults install order. Realm PRECEDENCE was considered and
27//! rejected for exactly that reason — it would let adding a tool to a
28//! high-priority realm silently change which binary a build runs.
29
30use std::collections::{BTreeMap, BTreeSet};
31
32/// A lenient view of a layer manifest — just what composition needs.
33///
34/// Deliberately NOT `LayerManifest`: that parse enforces the full install
35/// contract (counter, issued-at), and requiring it merely to discover whether a
36/// layer composes another would make `which` fail on layers that resolve fine
37/// today. Reading less is what lets this be additive.
38#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct LayerView {
40    pub includes: Vec<Include>,
41    /// Dispatchable tool names this layer exposes.
42    pub tools: Vec<String>,
43}
44
45/// Read the composition-relevant parts of a manifest. An unparseable manifest
46/// is an error, never an empty view — silently reporting "no includes" for a
47/// layer we could not read is the failure mode that hides a composition.
48pub fn view(bytes: &[u8]) -> Result<LayerView, ComposeError> {
49    let json: serde_json::Value =
50        serde_json::from_slice(bytes).map_err(|e| ComposeError::Unreadable(e.to_string()))?;
51    let mut v = LayerView::default();
52    let Some(entries) = json["manifests"].as_array() else {
53        return Ok(v);
54    };
55    for e in entries {
56        let ann = &e["annotations"];
57        let digest = e["digest"].as_str().unwrap_or_default().to_string();
58        match ann[crate::kind::ANN_KIND].as_str() {
59            // An EMPTY realm annotation is read as absent, not as a realm
60            // named "". Absent means "the including layer's realm", and a
61            // producer that writes the key with no value plainly means the
62            // same thing — reading it literally would leave the included
63            // layer with no realm and so no qualified form for its tools.
64            Some("layer") => v.includes.push(Include {
65                digest,
66                realm: ann[ANN_INCLUDE_REALM]
67                    .as_str()
68                    .filter(|s| !s.is_empty())
69                    .map(|s| s.to_string()),
70                layer: ann[ANN_INCLUDE_LAYER].as_str().map(|s| s.to_string()),
71            }),
72            // Absent kind = tool (back-compat, as everywhere else).
73            None => {
74                if let Some(t) = ann["eu.pulseengine.tool"].as_str() {
75                    v.tools.push(t.to_string());
76                }
77            }
78            // Any other kind is not dispatchable and not an include.
79            Some(_) => {}
80        }
81    }
82    Ok(v)
83}
84
85/// Annotation naming the realm an included layer belongs to. Absent means the
86/// including layer's own realm.
87pub const ANN_INCLUDE_REALM: &str = "eu.pulseengine.varve.include.realm";
88/// Annotation carrying the included layer's identity, for error messages that
89/// can name it before it has been fetched.
90pub const ANN_INCLUDE_LAYER: &str = "eu.pulseengine.varve.include.layer";
91
92/// How deep a composition graph may go. Generous for real use (a layer
93/// including a layer including a base), small enough that a malicious or
94/// mistaken graph cannot spend the client's time.
95pub const MAX_DEPTH: usize = 8;
96
97/// One layer this manifest composes.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct Include {
100    /// `sha256:<hex>` of the included layer's signed manifest — its identity.
101    pub digest: String,
102    /// The realm whose trust root verifies it. `None` = the including realm.
103    pub realm: Option<String>,
104    /// The included layer's identifier, for messages before it is resolved.
105    pub layer: Option<String>,
106}
107
108#[derive(Debug, thiserror::Error)]
109pub enum ComposeError {
110    #[error(
111        "composition cycle: layer {digest} includes itself, directly or through \
112         {via} — refusing to follow it"
113    )]
114    Cycle { digest: String, via: String },
115    #[error(
116        "composition is more than {MAX_DEPTH} layers deep — refusing to walk further \
117         (a layer graph this deep is a mistake, not a design)"
118    )]
119    TooDeep,
120    #[error("layer manifest could not be read for composition: {0}")]
121    Unreadable(String),
122    /// A name two layers of one composition both provide, which the pin has
123    /// not chosen between (REQ-REALM2-001 clause 4d).
124    ///
125    /// The old message named two layer DIGESTS and then said "Restrict the
126    /// pin's `tools`" — advice structurally incapable of working, because
127    /// `tools` filtered by NAME and the collision is one name. It must instead
128    /// name both providers WITH their realms and show the qualified form to
129    /// copy, which is a fix the reader can actually apply.
130    #[error(
131        "tool '{tool}' is provided by more than one layer of this composition — {first} \
132         and {second} — and the pin has not chosen between them. varve does not pick a \
133         winner: what a bare name runs is decided by the pin, never by install order. \
134         {fix}"
135    )]
136    AmbiguousTool {
137        tool: String,
138        first: String,
139        second: String,
140        fix: String,
141    },
142    /// The pin qualified a name with a realm that provides no such tool.
143    /// Failing closed matters here: silently falling back to the other realm
144    /// would run bytes the pin explicitly did not choose.
145    #[error(
146        "this project's pin selects '{selector}', but no layer of this composition from \
147         realm '{realm}' provides '{tool}' — it is provided by: {providers}. Fix the \
148         qualifier in varve.toml; varve will not substitute another realm's binary for \
149         the one the pin named."
150    )]
151    RealmProvidesNoSuchTool {
152        selector: String,
153        realm: String,
154        tool: String,
155        providers: String,
156    },
157    /// Boxed: six strings inline would make every `ComposeError` — and so
158    /// every `ResolveError` — large enough to move on the happy path.
159    #[error(transparent)]
160    ConflictingPayload(#[from] Box<PayloadConflict>),
161}
162
163/// Two layers of one composition offering the same (name, version) as
164/// different bytes (REQ-COMPOSEEXPORT-001 clause 2).
165#[derive(Debug, thiserror::Error, PartialEq, Eq)]
166#[error(
167    "{name} {version} is offered by two layers in this composition with DIFFERENT bytes: \
168     {first} has {first_digest}, {second} has {second_digest} — refusing to choose. \
169     Two realms disagreeing about what one name-and-version IS cannot both be exported; \
170     a name at different VERSIONS is legal and both export, but one (name, version) must \
171     be one artifact. Re-deposit one of the layers against the other's bytes, or drop the \
172     duplicate from the composition."
173)]
174pub struct PayloadConflict {
175    pub name: String,
176    pub version: String,
177    pub first: String,
178    pub first_digest: String,
179    pub second: String,
180    pub second_digest: String,
181}
182
183/// Where one payload of a composition came from and what it claims to be. The
184/// identity the collision rule is stated over (REQ-COMPOSEEXPORT-001 clause 2).
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct PayloadOrigin {
187    pub name: String,
188    pub version: String,
189    /// `sha256:<hex>` of the bytes, as the signed manifest records it.
190    pub digest: String,
191    /// The realm whose trust root vouched for the layer offering it, and the
192    /// layer itself — both, because the error must name the realms that
193    /// disagree, not merely the layers.
194    pub realm: String,
195    pub layer: String,
196}
197
198impl PayloadOrigin {
199    /// How this payload is named in an error: realm first, since the realms
200    /// are what disagree.
201    fn describe(&self) -> String {
202        format!("realm '{}' layer {}", self.realm, self.layer)
203    }
204}
205
206/// Union the payloads every layer of a composition offers, applying the rule
207/// that is NOT the tool rule (REQ-COMPOSEEXPORT-001 clause 2).
208///
209/// A tool name in two layers is ambiguous because dispatch must pick ONE
210/// binary for a bare name — so `union_tools` refuses it. A payload is not
211/// dispatched: it is placed in a registry keyed by (name, version), and two
212/// versions of one crate are the ordinary case a lockfile requires. So:
213///
214/// * the same name at DIFFERENT versions — both export;
215/// * the same name AND version with the SAME digest — one copy (a diamond
216///   offers a shared base twice; that is agreement, not conflict);
217/// * the same name AND version with DIFFERENT digests — an ERROR naming both
218///   realms, because two realms then disagree about what those bytes are and
219///   varve does not pick a winner.
220///
221/// Order is preserved (root layer first), so the export is a function of the
222/// composition rather than of a map's iteration order.
223pub fn union_payloads<T>(
224    items: Vec<(PayloadOrigin, T)>,
225) -> Result<Vec<(PayloadOrigin, T)>, ComposeError> {
226    let mut first_seen: BTreeMap<(String, String), PayloadOrigin> = BTreeMap::new();
227    let mut out = Vec::new();
228    for (origin, payload) in items {
229        let key = (origin.name.clone(), origin.version.clone());
230        match first_seen.get(&key) {
231            Some(first) if first.digest != origin.digest => {
232                return Err(ComposeError::ConflictingPayload(Box::new(
233                    PayloadConflict {
234                        name: origin.name.clone(),
235                        version: origin.version.clone(),
236                        first: first.describe(),
237                        first_digest: first.digest.clone(),
238                        second: origin.describe(),
239                        second_digest: origin.digest,
240                    },
241                )));
242            }
243            // Same bytes, offered twice: export one copy, not an error.
244            Some(_) => continue,
245            None => {
246                first_seen.insert(key, origin.clone());
247                out.push((origin, payload));
248            }
249        }
250    }
251    Ok(out)
252}
253
254/// The layers a view directly composes, in manifest order.
255pub fn includes(v: &LayerView) -> Vec<Include> {
256    v.includes.clone()
257}
258
259/// One layer of a walked composition, with the realm whose root vouches for it.
260///
261/// The realm is carried through the walk rather than looked up afterwards
262/// because it is a property of the EDGE — an `[[include]]` names the realm that
263/// verifies the layer it points at — and because a refusal that cannot name the
264/// realms is the refusal clause 4d exists to replace.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct Walked {
267    pub digest: String,
268    /// The realm naming this layer's trust root. Empty where the composition
269    /// names none (a pin with no `realm`), in which case there is no qualified
270    /// form for its tools and the refusal must say so.
271    pub realm: String,
272    pub view: LayerView,
273}
274
275/// Walk a composition graph from a root manifest, refusing cycles and excessive
276/// depth. `fetch` supplies a manifest for a digest, or `None` if that layer is
277/// not installed — a missing layer is the caller's error to report (with its
278/// corrective `varve install`), not this walker's to invent.
279///
280/// `root_realm` labels the root; each include labels its child, inheriting the
281/// including layer's realm where it names none (the annotation's documented
282/// meaning).
283///
284/// Returns the visit order, root first, so callers can select tools predictably.
285pub fn walk<F>(
286    root_digest: &str,
287    root_realm: &str,
288    root: &LayerView,
289    mut fetch: F,
290) -> Result<Vec<Walked>, ComposeError>
291where
292    F: FnMut(&str) -> Option<LayerView>,
293{
294    let mut out = vec![Walked {
295        digest: root_digest.to_string(),
296        realm: root_realm.to_string(),
297        view: root.clone(),
298    }];
299    let mut emitted: BTreeSet<String> = BTreeSet::new();
300    emitted.insert(root_digest.to_string());
301    // (digest, realm, view, ancestors-on-this-path). A CYCLE is a digest
302    // reappearing on its OWN path — not merely one seen before. An earlier
303    // version used a global `seen`, which reported a DIAMOND (two layers
304    // sharing a base) as a cycle, with a message falsely claiming the layer
305    // included itself. A shared base is the most ordinary composition there is.
306    let mut stack: Vec<(String, String, LayerView, BTreeSet<String>)> = vec![(
307        root_digest.to_string(),
308        root_realm.to_string(),
309        root.clone(),
310        BTreeSet::from([root_digest.to_string()]),
311    )];
312    while let Some((from, realm, view, path)) = stack.pop() {
313        if path.len() > MAX_DEPTH {
314            return Err(ComposeError::TooDeep);
315        }
316        for inc in includes(&view) {
317            if path.contains(&inc.digest) {
318                return Err(ComposeError::Cycle {
319                    digest: inc.digest.clone(),
320                    via: from.clone(),
321                });
322            }
323            let Some(child) = fetch(&inc.digest) else {
324                // Not installed. The caller names it and how to fix it.
325                continue;
326            };
327            let child_realm = inc.realm.clone().unwrap_or_else(|| realm.clone());
328            // A layer reachable by two paths is walked once, not refused.
329            if emitted.insert(inc.digest.clone()) {
330                out.push(Walked {
331                    digest: inc.digest.clone(),
332                    realm: child_realm.clone(),
333                    view: child.clone(),
334                });
335            }
336            let mut child_path = path.clone();
337            child_path.insert(inc.digest.clone());
338            stack.push((inc.digest.clone(), child_realm, child, child_path));
339        }
340    }
341    Ok(out)
342}
343
344/// One layer's claim to a dispatchable name.
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub struct ToolProvider {
347    pub tool: String,
348    /// The realm whose root vouches for the providing layer. Empty where the
349    /// composition names none.
350    pub realm: String,
351    /// The providing layer's identity, for messages a human reads.
352    pub layer: String,
353    /// The providing layer's manifest digest — the store key.
354    pub digest: String,
355}
356
357impl ToolProvider {
358    /// How a pin names this provider: `realm/tool`. `None` where the layer
359    /// belongs to no named realm — there is then no qualified form, and a
360    /// refusal must say that rather than print one that cannot work.
361    pub fn qualified(&self) -> Option<String> {
362        (!self.realm.is_empty()).then(|| format!("{}/{}", self.realm, self.tool))
363    }
364
365    /// How this provider is named in an error: realm first, since the realms
366    /// are what a reader must tell apart.
367    fn describe(&self) -> String {
368        if self.realm.is_empty() {
369            format!("layer {} (no realm named)", self.layer)
370        } else {
371            format!("realm '{}' layer {}", self.realm, self.layer)
372        }
373    }
374}
375
376/// Decide, for every dispatchable name a composition exposes, the ONE provider
377/// a bare name resolves to (REQ-REALM2-001 clauses 4c and 4d).
378///
379/// `chosen` is the pin's realm-qualified selection, tool name → realm. A name
380/// with a single provider needs no entry and is unaffected — every pin written
381/// before this existed keeps resolving byte for byte. A name with several
382/// providers resolves only where the pin chose, and the choice is the pin's
383/// alone: nothing here reads install order, partition order or a precedence
384/// list, because any of those would let adding a tool to one realm silently
385/// change which binary a build runs.
386///
387/// Providers are given root-layer-first; that order decides only which one an
388/// error names first, never which one wins.
389pub fn select_tools(
390    providers: &[ToolProvider],
391    chosen: &BTreeMap<String, String>,
392) -> Result<BTreeMap<String, ToolProvider>, ComposeError> {
393    let mut by_name: BTreeMap<&str, Vec<&ToolProvider>> = BTreeMap::new();
394    for p in providers {
395        let slot = by_name.entry(p.tool.as_str()).or_default();
396        // One layer offering a name twice (manifest and `bin/` both say so) is
397        // one provider, not a collision with itself.
398        if !slot.iter().any(|q| q.digest == p.digest) {
399            slot.push(p);
400        }
401    }
402    let mut out = BTreeMap::new();
403    for (tool, offers) in by_name {
404        let picked: Vec<&ToolProvider> = match chosen.get(tool) {
405            Some(realm) => offers
406                .iter()
407                .copied()
408                .filter(|p| &p.realm == realm)
409                .collect(),
410            None => offers.clone(),
411        };
412        match picked.as_slice() {
413            [only] => {
414                out.insert(tool.to_string(), (*only).clone());
415            }
416            [] => {
417                // The pin qualified with a realm that provides nothing here.
418                let realm = chosen.get(tool).cloned().unwrap_or_default();
419                return Err(ComposeError::RealmProvidesNoSuchTool {
420                    selector: format!("{realm}/{tool}"),
421                    realm,
422                    tool: tool.to_string(),
423                    providers: offers
424                        .iter()
425                        .map(|p| p.describe())
426                        .collect::<Vec<_>>()
427                        .join(", "),
428                });
429            }
430            [first, second, ..] => {
431                return Err(ComposeError::AmbiguousTool {
432                    tool: tool.to_string(),
433                    first: first.describe(),
434                    second: second.describe(),
435                    fix: fix_for(tool, first, second, chosen.contains_key(tool)),
436                });
437            }
438        }
439    }
440    Ok(out)
441}
442
443/// The corrective half of clause 4d: a line the reader can paste, or a plain
444/// statement of why no such line exists for this pair.
445fn fix_for(
446    tool: &str,
447    first: &ToolProvider,
448    second: &ToolProvider,
449    already_qualified: bool,
450) -> String {
451    match (first.qualified(), second.qualified()) {
452        (Some(a), Some(b)) if first.realm != second.realm => format!(
453            "Choose one in varve.toml: tools = [\"{a}\"] — or tools = [\"{b}\"]. The layer you \
454             do not choose stays installed and verified, and `varve run {a}` / `varve run {b}` \
455             still reach either one."
456        ),
457        // Two layers of ONE realm, or a layer with no realm at all: a realm
458        // qualifier cannot separate these, and saying so is the honest answer.
459        _ if already_qualified || first.realm == second.realm => format!(
460            "Both are in realm '{}', so a realm qualifier cannot separate them — one of those \
461             two layers must stop exposing '{tool}', or pin the layer that provides the one \
462             you want directly.",
463            first.realm
464        ),
465        _ => format!(
466            "One of these layers belongs to no named realm, so there is no qualified form for \
467             it: define its realm in varve-realms.toml and name it in the pin's `realm`, then \
468             choose with tools = [\"<realm>/{tool}\"]."
469        ),
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    /// A manifest with the given tools and includes.
478    fn manifest(layer: &str, tools: &[&str], includes: &[(&str, &str)]) -> LayerView {
479        let mut entries: Vec<String> = tools
480            .iter()
481            .map(|t| {
482                format!(
483                    r#"{{"digest":"sha256:{t}","annotations":{{"eu.pulseengine.tool":"{t}"}}}}"#
484                )
485            })
486            .collect();
487        for (digest, realm) in includes {
488            entries.push(format!(
489                r#"{{"digest":"{digest}","annotations":{{"eu.pulseengine.varve.kind":"layer","{ANN_INCLUDE_REALM}":"{realm}"}}}}"#
490            ));
491        }
492        let json = format!(
493            r#"{{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json",
494"artifactType":"application/vnd.pulseengine.varve.layer.v1+json",
495"annotations":{{"eu.pulseengine.varve.layer":"{layer}","eu.pulseengine.varve.channel":"qualified",
496"eu.pulseengine.varve.counter":"1","org.opencontainers.image.created":"2026-08-01T00:00:00Z"}},
497"manifests":[{}]}}"#,
498            entries.join(",")
499        );
500        let _ = layer;
501        view(json.as_bytes()).unwrap()
502    }
503
504    /// Every provider a walk exposes, in walk order — the shape `resolve`
505    /// hands `select_tools`, built here from views so the compose tests reason
506    /// about the same data the binary does.
507    fn providers(walked: &[Walked]) -> Vec<ToolProvider> {
508        walked
509            .iter()
510            .flat_map(|w| {
511                w.view.tools.iter().map(|t| ToolProvider {
512                    tool: t.clone(),
513                    realm: w.realm.clone(),
514                    layer: "2026.08.0".into(),
515                    digest: w.digest.clone(),
516                })
517            })
518            .collect()
519    }
520
521    /// `select_tools` with nothing chosen — the pre-v0.29.0 behaviour, and
522    /// still what an unrestricted pin gets.
523    fn unchosen(walked: &[Walked]) -> Result<BTreeMap<String, ToolProvider>, ComposeError> {
524        select_tools(&providers(walked), &BTreeMap::new())
525    }
526
527    // rivet: verifies REQ-COMPOSE-001
528    #[test]
529    fn a_composition_exposes_both_layers_tools() {
530        let upstream = manifest("2026.08.0", &["wasm-tools", "cargo-component"], &[]);
531        let root = manifest(
532            "2026.08.0",
533            &["rivet", "meld"],
534            &[("sha256:up", "bytecodealliance")],
535        );
536        let inc = includes(&root);
537        assert_eq!(inc.len(), 1);
538        assert_eq!(inc[0].digest, "sha256:up");
539        assert_eq!(inc[0].realm.as_deref(), Some("bytecodealliance"));
540
541        let layers = walk("sha256:root", "pulseengine", &root, |d| {
542            (d == "sha256:up").then(|| upstream.clone())
543        })
544        .unwrap();
545        assert_eq!(layers.len(), 2, "root plus the included layer");
546        // The include's realm labels the layer it points at; the root keeps
547        // the pin's own.
548        assert_eq!(layers[0].realm, "pulseengine");
549        assert_eq!(layers[1].realm, "bytecodealliance");
550        let tools = unchosen(&layers).unwrap();
551        // The producing half is now answerable alongside the checking half.
552        for t in ["rivet", "meld", "wasm-tools", "cargo-component"] {
553            assert!(tools.contains_key(t), "{t} missing from the composition");
554        }
555        assert_eq!(tools["wasm-tools"].digest, "sha256:up");
556        assert_eq!(tools["rivet"].digest, "sha256:root");
557    }
558
559    // rivet: verifies REQ-COMPOSE-001
560    #[test]
561    fn a_tool_in_two_layers_is_an_error_not_a_silent_choice() {
562        // Both layers ship `wasm-tools`. varve must not pick one.
563        let upstream = manifest("2026.08.0", &["wasm-tools"], &[]);
564        let root = manifest(
565            "2026.08.0",
566            &["wasm-tools"],
567            &[("sha256:up", "bytecodealliance")],
568        );
569        let layers = walk("sha256:root", "pulseengine", &root, |d| {
570            (d == "sha256:up").then(|| upstream.clone())
571        })
572        .unwrap();
573        match unchosen(&layers) {
574            Err(ComposeError::AmbiguousTool { tool, .. }) => assert_eq!(tool, "wasm-tools"),
575            other => panic!("expected AmbiguousTool, got {other:?}"),
576        }
577    }
578
579    // rivet: verifies REQ-REALM2-001
580    #[test]
581    fn a_realm_qualifier_settles_a_collision_the_tools_filter_never_could() {
582        // Clause 4a. `tools = ["rivet", "synth"]` filters by NAME, and the
583        // collision is two layers exposing the SAME name — so no value of the
584        // old filter could ever disambiguate. A realm can.
585        let upstream = manifest("2026.08.0", &["wasm-tools"], &[]);
586        let root = manifest(
587            "2026.09.0",
588            &["wasm-tools", "rivet"],
589            &[("sha256:up", "bytecodealliance")],
590        );
591        let layers = walk("sha256:root", "pulseengine", &root, |d| {
592            (d == "sha256:up").then(|| upstream.clone())
593        })
594        .unwrap();
595        let all = providers(&layers);
596
597        for (realm, digest) in [
598            ("bytecodealliance", "sha256:up"),
599            ("pulseengine", "sha256:root"),
600        ] {
601            let chosen = BTreeMap::from([("wasm-tools".to_string(), realm.to_string())]);
602            let picked = select_tools(&all, &chosen).unwrap();
603            assert_eq!(
604                picked["wasm-tools"].digest, digest,
605                "the pin chose realm '{realm}'"
606            );
607            assert_eq!(picked["wasm-tools"].realm, realm);
608            // A bare name where nothing collides is untouched by any of this.
609            assert_eq!(picked["rivet"].digest, "sha256:root");
610        }
611    }
612
613    // rivet: verifies REQ-REALM2-001
614    #[test]
615    fn a_qualifier_naming_a_realm_that_provides_nothing_is_refused_not_ignored() {
616        // Failing closed is the whole point: quietly falling back to the other
617        // realm would run bytes the pin explicitly did not choose, which is the
618        // silent substitution the qualifier exists to prevent.
619        let upstream = manifest("2026.08.0", &["wasm-tools"], &[]);
620        let root = manifest(
621            "2026.09.0",
622            &["wasm-tools"],
623            &[("sha256:up", "bytecodealliance")],
624        );
625        let layers = walk("sha256:root", "pulseengine", &root, |d| {
626            (d == "sha256:up").then(|| upstream.clone())
627        })
628        .unwrap();
629        let chosen = BTreeMap::from([("wasm-tools".to_string(), "acme".to_string())]);
630        let err = select_tools(&providers(&layers), &chosen).unwrap_err();
631        let msg = err.to_string();
632        assert!(
633            matches!(err, ComposeError::RealmProvidesNoSuchTool { .. }),
634            "{msg}"
635        );
636        assert!(msg.contains("acme/wasm-tools"), "{msg}");
637        // …and it names who DOES provide it, or the reader cannot fix the typo.
638        assert!(
639            msg.contains("pulseengine") && msg.contains("bytecodealliance"),
640            "{msg}"
641        );
642    }
643
644    // rivet: verifies REQ-REALM2-001
645    #[test]
646    fn the_refusal_names_both_realms_and_shows_a_qualified_form_that_works() {
647        // Clause 4d. The message it replaces named two layer DIGESTS and then
648        // advised "Restrict the pin's `tools`" — a fix that cannot work, and a
649        // persona who tried it twice reported it doing nothing.
650        let upstream = manifest("2026.08.0", &["wasm-tools"], &[]);
651        let root = manifest(
652            "2026.09.0",
653            &["wasm-tools"],
654            &[("sha256:up", "bytecodealliance")],
655        );
656        let layers = walk("sha256:root", "pulseengine", &root, |d| {
657            (d == "sha256:up").then(|| upstream.clone())
658        })
659        .unwrap();
660        let msg = unchosen(&layers).unwrap_err().to_string();
661        assert!(
662            msg.contains("realm 'pulseengine'") && msg.contains("realm 'bytecodealliance'"),
663            "both providers must be named WITH their realms: {msg}"
664        );
665        assert!(
666            msg.contains("tools = [\"pulseengine/wasm-tools\"]")
667                && msg.contains("tools = [\"bytecodealliance/wasm-tools\"]"),
668            "both qualified forms must be there to copy: {msg}"
669        );
670        assert!(
671            !msg.contains("Restrict the pin's `tools`"),
672            "the advice that cannot work must be gone: {msg}"
673        );
674    }
675
676    // rivet: verifies REQ-REALM2-001
677    #[test]
678    fn two_layers_of_one_realm_are_told_a_qualifier_cannot_help_them() {
679        // The honest edge of clause 4d. A realm qualifier separates realms; it
680        // cannot separate two layers inside one. Printing a form that would not
681        // work is exactly the failure this requirement exists to end, so the
682        // refusal says so instead.
683        let base = manifest("2026.08.0", &["wasm-tools"], &[]);
684        let root = manifest("2026.09.0", &["wasm-tools"], &[("sha256:base", "")]);
685        let layers = walk("sha256:root", "pulseengine", &root, |d| {
686            (d == "sha256:base").then(|| base.clone())
687        })
688        .unwrap();
689        let msg = unchosen(&layers).unwrap_err().to_string();
690        assert!(
691            msg.contains("a realm qualifier cannot separate them"),
692            "an unusable qualified form must not be offered: {msg}"
693        );
694    }
695
696    // rivet: verifies REQ-REALM2-001
697    #[test]
698    fn one_layer_declaring_a_name_twice_is_not_a_collision_with_itself() {
699        // `resolve` offers a name from the signed manifest AND from `bin/`, so
700        // the ordinary layer produces two offers for one tool. Treating that as
701        // ambiguity would refuse every single-realm pin varve has ever had.
702        let twice = vec![
703            ToolProvider {
704                tool: "rivet".into(),
705                realm: "pulseengine".into(),
706                layer: "2026.09.0".into(),
707                digest: "sha256:root".into(),
708            },
709            ToolProvider {
710                tool: "rivet".into(),
711                realm: "pulseengine".into(),
712                layer: "2026.09.0".into(),
713                digest: "sha256:root".into(),
714            },
715        ];
716        let picked = select_tools(&twice, &BTreeMap::new()).unwrap();
717        assert_eq!(picked["rivet"].digest, "sha256:root");
718    }
719
720    // rivet: verifies REQ-REALM2-001
721    #[test]
722    fn an_include_inherits_the_including_realm_where_it_names_none() {
723        // The annotation's documented meaning ("absent means the including
724        // layer's own realm"), which is also what keeps every single-realm
725        // composition addressable: without inheritance the included layer would
726        // have no qualified form at all.
727        let base = manifest("2026.08.0", &["base"], &[]);
728        let root = manifest("2026.09.0", &["rivet"], &[("sha256:base", "")]);
729        let layers = walk("sha256:root", "pulseengine", &root, |d| {
730            (d == "sha256:base").then(|| base.clone())
731        })
732        .unwrap();
733        assert_eq!(layers[1].realm, "pulseengine");
734        let picked = unchosen(&layers).unwrap();
735        assert_eq!(
736            picked["base"].qualified().as_deref(),
737            Some("pulseengine/base")
738        );
739    }
740
741    // rivet: verifies REQ-COMPOSE-001
742    #[test]
743    fn depth_is_bounded_so_a_long_chain_cannot_exhaust_the_walker() {
744        // The bound is what keeps "refused" from meaning "followed until the
745        // process aborts". Re-verification found `verify` recursing without it
746        // and stack-overflowing on a self-referencing store entry; both walkers
747        // are now bounded.
748        let leaf = manifest("2026.08.0", &["leaf"], &[]);
749        // A chain longer than MAX_DEPTH, each link including the next.
750        let chain: Vec<LayerView> = (0..=MAX_DEPTH + 2)
751            .map(|i| manifest("2026.08.0", &["t"], &[(&format!("sha256:{}", i + 1), "r")]))
752            .collect();
753        let err = walk("sha256:0", "r", &chain[0], |d: &str| {
754            let n: usize = d.trim_start_matches("sha256:").parse().ok()?;
755            chain.get(n).cloned().or_else(|| Some(leaf.clone()))
756        })
757        .unwrap_err();
758        assert!(matches!(err, ComposeError::TooDeep), "got {err:?}");
759    }
760
761    // rivet: verifies REQ-COMPOSE-001
762    #[test]
763    fn a_chain_exactly_at_the_bound_is_walked_not_refused() {
764        // The other side of the same `>`. `MAX_DEPTH` is documented as how deep
765        // a composition MAY go, so a graph exactly that deep is legal — an
766        // off-by-one here would refuse the deepest permitted composition while
767        // the message said graphs of this depth are a mistake.
768        let chain: Vec<LayerView> = (0..MAX_DEPTH)
769            .map(|i| {
770                let tool = format!("t{i}");
771                if i + 1 == MAX_DEPTH {
772                    manifest("2026.08.0", &[&tool], &[])
773                } else {
774                    manifest(
775                        "2026.08.0",
776                        &[&tool],
777                        &[(&format!("sha256:{}", i + 1), "r")],
778                    )
779                }
780            })
781            .collect();
782        let walked = walk("sha256:0", "r", &chain[0], |d: &str| {
783            let n: usize = d.trim_start_matches("sha256:").parse().ok()?;
784            chain.get(n).cloned()
785        })
786        .expect("a chain exactly MAX_DEPTH long is within the bound");
787        assert_eq!(walked.len(), MAX_DEPTH);
788    }
789
790    // rivet: verifies REQ-REALM2-001
791    #[test]
792    fn a_collision_involving_a_layer_with_no_realm_says_there_is_no_qualified_form() {
793        // A pin that names no realm gives its own layer no realm name, so
794        // there IS no `realm/tool` for it. Printing one anyway would be the
795        // same failure clause 4d exists to end — advice that cannot work — so
796        // the refusal points at the thing that WOULD make a qualifier possible.
797        let upstream = manifest("2026.08.0", &["wasm-tools"], &[]);
798        let root = manifest(
799            "2026.09.0",
800            &["wasm-tools"],
801            &[("sha256:up", "bytecodealliance")],
802        );
803        let layers = walk("sha256:root", "", &root, |d| {
804            (d == "sha256:up").then(|| upstream.clone())
805        })
806        .unwrap();
807        let msg = unchosen(&layers).unwrap_err().to_string();
808        assert!(
809            msg.contains("belongs to no named realm") && msg.contains("varve-realms.toml"),
810            "the refusal must name the fix that exists, not a qualified form that does not: {msg}"
811        );
812        assert!(
813            !msg.contains("Both are in realm"),
814            "these two are NOT in one realm; one has none: {msg}"
815        );
816    }
817
818    // rivet: verifies REQ-COMPOSE-001
819    #[test]
820    fn a_diamond_is_walked_once_not_refused_as_a_cycle() {
821        // A includes B and C; both include D. This terminates and is the most
822        // ordinary composition shape there is — two layers sharing a base.
823        // The first version reported it as a cycle, with a message claiming D
824        // "includes itself". Found by clean-room review.
825        let d = manifest("2026.08.0", &["base"], &[]);
826        let b = manifest("2026.08.0", &["b"], &[("sha256:d", "r")]);
827        let c = manifest("2026.08.0", &["c"], &[("sha256:d", "r")]);
828        let a = manifest("2026.08.0", &["a"], &[("sha256:b", "r"), ("sha256:c", "r")]);
829        let walked = walk("sha256:a", "r", &a, |q| match q {
830            "sha256:b" => Some(b.clone()),
831            "sha256:c" => Some(c.clone()),
832            "sha256:d" => Some(d.clone()),
833            _ => None,
834        })
835        .unwrap();
836        assert_eq!(walked.len(), 4, "A, B, C and D each once: {walked:?}");
837        // …and the shared base's tool resolves exactly once, not ambiguously.
838        let tools = unchosen(&walked).unwrap();
839        assert_eq!(tools["base"].digest, "sha256:d");
840    }
841
842    // rivet: verifies REQ-COMPOSE-001
843    #[test]
844    fn a_cycle_is_refused_not_followed() {
845        // A includes B; B includes A. Following it would not terminate.
846        let a = manifest("2026.08.0", &["x"], &[("sha256:b", "r")]);
847        let b = manifest("2026.08.0", &["y"], &[("sha256:a", "r")]);
848        let (ac, bc) = (a.clone(), b.clone());
849        let err = walk("sha256:a", "r", &a, move |d| match d {
850            "sha256:b" => Some(bc.clone()),
851            "sha256:a" => Some(ac.clone()),
852            _ => None,
853        })
854        .unwrap_err();
855        assert!(matches!(err, ComposeError::Cycle { .. }), "got {err:?}");
856    }
857
858    // rivet: verifies REQ-COMPOSE-001
859    #[test]
860    fn an_uninstalled_include_is_skipped_for_the_caller_to_report() {
861        // walk() does not invent a fetch. A missing layer is the caller's
862        // error to report, with its corrective `varve install`.
863        let root = manifest("2026.08.0", &["rivet"], &[("sha256:missing", "other")]);
864        let layers = walk("sha256:root", "r", &root, |_| None).unwrap();
865        assert_eq!(layers.len(), 1, "only the root resolved");
866        assert_eq!(
867            includes(&root).len(),
868            1,
869            "but the include is still declared"
870        );
871    }
872
873    /// One payload offered by a named realm.
874    fn offered(realm: &str, name: &str, version: &str, digest: &str) -> (PayloadOrigin, ()) {
875        (
876            PayloadOrigin {
877                name: name.into(),
878                version: version.into(),
879                digest: format!("sha256:{digest}"),
880                realm: realm.into(),
881                layer: "2026.08.0".into(),
882            },
883            (),
884        )
885    }
886
887    // rivet: verifies REQ-COMPOSEEXPORT-001
888    #[test]
889    fn two_versions_of_one_crate_both_export() {
890        // Clause 2: the collision rule is NOT the tool rule. `serde 1.0.200`
891        // and `serde 1.0.210` are not ambiguous — a lockfile that names two
892        // majors of one crate NEEDS both present to build offline, and varve's
893        // own lockfile has 14 such names.
894        let kept = union_payloads(vec![
895            offered("pulseengine", "serde", "1.0.200", "aa"),
896            offered("bytecodealliance", "serde", "1.0.210", "bb"),
897        ])
898        .unwrap();
899        assert_eq!(kept.len(), 2);
900        let mut vers: Vec<&str> = kept.iter().map(|(o, _)| o.version.as_str()).collect();
901        vers.sort();
902        assert_eq!(vers, ["1.0.200", "1.0.210"]);
903    }
904
905    // rivet: verifies REQ-COMPOSEEXPORT-001
906    #[test]
907    fn the_same_name_and_version_with_the_same_bytes_exports_once() {
908        // A diamond: two layers each including the same base. Both offer the
909        // same crate at the same digest — that is two realms AGREEING, and
910        // exporting the bytes twice or refusing them both would be wrong.
911        let kept = union_payloads(vec![
912            offered("pulseengine", "cfg-if", "1.0.0", "aa"),
913            offered("bytecodealliance", "cfg-if", "1.0.0", "aa"),
914        ])
915        .unwrap();
916        assert_eq!(kept.len(), 1, "one copy of agreed bytes: {kept:?}");
917        assert_eq!(kept[0].0.realm, "pulseengine", "the first offer wins");
918    }
919
920    // rivet: verifies REQ-COMPOSEEXPORT-001
921    #[test]
922    fn the_same_name_and_version_with_different_bytes_names_both_realms() {
923        // Clause 2's error case. Two realms disagree about what `cfg-if 1.0.0`
924        // IS; picking either would put bytes one realm never vouched for into
925        // an export the consumer believes is verified. The message must name
926        // BOTH realms, or the reader cannot tell which side to fix.
927        let err = union_payloads(vec![
928            offered("pulseengine", "cfg-if", "1.0.0", "aa"),
929            offered("bytecodealliance", "cfg-if", "1.0.0", "bb"),
930        ])
931        .unwrap_err();
932        let msg = err.to_string();
933        assert!(matches!(err, ComposeError::ConflictingPayload(_)), "{msg}");
934        assert!(msg.contains("cfg-if") && msg.contains("1.0.0"), "{msg}");
935        assert!(
936            msg.contains("pulseengine") && msg.contains("bytecodealliance"),
937            "both realms must be named: {msg}"
938        );
939        assert!(
940            msg.contains("sha256:aa") && msg.contains("sha256:bb"),
941            "both digests must be named: {msg}"
942        );
943    }
944
945    // rivet: verifies REQ-COMPOSE-001
946    #[test]
947    fn a_layer_without_includes_composes_to_itself() {
948        // Back-compat: every existing layer has no `layer` entries and must
949        // behave exactly as before.
950        let plain = manifest("2026.08.0", &["rivet", "meld"], &[]);
951        assert!(includes(&plain).is_empty());
952        let layers = walk("sha256:root", "r", &plain, |_| None).unwrap();
953        assert_eq!(layers.len(), 1);
954        assert_eq!(unchosen(&layers).unwrap().len(), 2);
955    }
956}