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
20use std::collections::{BTreeMap, BTreeSet};
21
22/// A lenient view of a layer manifest — just what composition needs.
23///
24/// Deliberately NOT `LayerManifest`: that parse enforces the full install
25/// contract (counter, issued-at), and requiring it merely to discover whether a
26/// layer composes another would make `which` fail on layers that resolve fine
27/// today. Reading less is what lets this be additive.
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub struct LayerView {
30    pub includes: Vec<Include>,
31    /// Dispatchable tool names this layer exposes.
32    pub tools: Vec<String>,
33}
34
35/// Read the composition-relevant parts of a manifest. An unparseable manifest
36/// is an error, never an empty view — silently reporting "no includes" for a
37/// layer we could not read is the failure mode that hides a composition.
38pub fn view(bytes: &[u8]) -> Result<LayerView, ComposeError> {
39    let json: serde_json::Value =
40        serde_json::from_slice(bytes).map_err(|e| ComposeError::Unreadable(e.to_string()))?;
41    let mut v = LayerView::default();
42    let Some(entries) = json["manifests"].as_array() else {
43        return Ok(v);
44    };
45    for e in entries {
46        let ann = &e["annotations"];
47        let digest = e["digest"].as_str().unwrap_or_default().to_string();
48        match ann[crate::kind::ANN_KIND].as_str() {
49            Some("layer") => v.includes.push(Include {
50                digest,
51                realm: ann[ANN_INCLUDE_REALM].as_str().map(|s| s.to_string()),
52                layer: ann[ANN_INCLUDE_LAYER].as_str().map(|s| s.to_string()),
53            }),
54            // Absent kind = tool (back-compat, as everywhere else).
55            None => {
56                if let Some(t) = ann["eu.pulseengine.tool"].as_str() {
57                    v.tools.push(t.to_string());
58                }
59            }
60            // Any other kind is not dispatchable and not an include.
61            Some(_) => {}
62        }
63    }
64    Ok(v)
65}
66
67/// Annotation naming the realm an included layer belongs to. Absent means the
68/// including layer's own realm.
69pub const ANN_INCLUDE_REALM: &str = "eu.pulseengine.varve.include.realm";
70/// Annotation carrying the included layer's identity, for error messages that
71/// can name it before it has been fetched.
72pub const ANN_INCLUDE_LAYER: &str = "eu.pulseengine.varve.include.layer";
73
74/// How deep a composition graph may go. Generous for real use (a layer
75/// including a layer including a base), small enough that a malicious or
76/// mistaken graph cannot spend the client's time.
77pub const MAX_DEPTH: usize = 8;
78
79/// One layer this manifest composes.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Include {
82    /// `sha256:<hex>` of the included layer's signed manifest — its identity.
83    pub digest: String,
84    /// The realm whose trust root verifies it. `None` = the including realm.
85    pub realm: Option<String>,
86    /// The included layer's identifier, for messages before it is resolved.
87    pub layer: Option<String>,
88}
89
90#[derive(Debug, thiserror::Error)]
91pub enum ComposeError {
92    #[error(
93        "composition cycle: layer {digest} includes itself, directly or through \
94         {via} — refusing to follow it"
95    )]
96    Cycle { digest: String, via: String },
97    #[error(
98        "composition is more than {MAX_DEPTH} layers deep — refusing to walk further \
99         (a layer graph this deep is a mistake, not a design)"
100    )]
101    TooDeep,
102    #[error("layer manifest could not be read for composition: {0}")]
103    Unreadable(String),
104    #[error(
105        "tool '{tool}' is exposed by more than one layer in this composition \
106         ({first} and {second}) — refusing to choose. Restrict the pin's `tools`, \
107         or remove the duplicate from one layer."
108    )]
109    AmbiguousTool {
110        tool: String,
111        first: String,
112        second: String,
113    },
114    /// Boxed: six strings inline would make every `ComposeError` — and so
115    /// every `ResolveError` — large enough to move on the happy path.
116    #[error(transparent)]
117    ConflictingPayload(#[from] Box<PayloadConflict>),
118}
119
120/// Two layers of one composition offering the same (name, version) as
121/// different bytes (REQ-COMPOSEEXPORT-001 clause 2).
122#[derive(Debug, thiserror::Error, PartialEq, Eq)]
123#[error(
124    "{name} {version} is offered by two layers in this composition with DIFFERENT bytes: \
125     {first} has {first_digest}, {second} has {second_digest} — refusing to choose. \
126     Two realms disagreeing about what one name-and-version IS cannot both be exported; \
127     a name at different VERSIONS is legal and both export, but one (name, version) must \
128     be one artifact. Re-deposit one of the layers against the other's bytes, or drop the \
129     duplicate from the composition."
130)]
131pub struct PayloadConflict {
132    pub name: String,
133    pub version: String,
134    pub first: String,
135    pub first_digest: String,
136    pub second: String,
137    pub second_digest: String,
138}
139
140/// Where one payload of a composition came from and what it claims to be. The
141/// identity the collision rule is stated over (REQ-COMPOSEEXPORT-001 clause 2).
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct PayloadOrigin {
144    pub name: String,
145    pub version: String,
146    /// `sha256:<hex>` of the bytes, as the signed manifest records it.
147    pub digest: String,
148    /// The realm whose trust root vouched for the layer offering it, and the
149    /// layer itself — both, because the error must name the realms that
150    /// disagree, not merely the layers.
151    pub realm: String,
152    pub layer: String,
153}
154
155impl PayloadOrigin {
156    /// How this payload is named in an error: realm first, since the realms
157    /// are what disagree.
158    fn describe(&self) -> String {
159        format!("realm '{}' layer {}", self.realm, self.layer)
160    }
161}
162
163/// Union the payloads every layer of a composition offers, applying the rule
164/// that is NOT the tool rule (REQ-COMPOSEEXPORT-001 clause 2).
165///
166/// A tool name in two layers is ambiguous because dispatch must pick ONE
167/// binary for a bare name — so `union_tools` refuses it. A payload is not
168/// dispatched: it is placed in a registry keyed by (name, version), and two
169/// versions of one crate are the ordinary case a lockfile requires. So:
170///
171/// * the same name at DIFFERENT versions — both export;
172/// * the same name AND version with the SAME digest — one copy (a diamond
173///   offers a shared base twice; that is agreement, not conflict);
174/// * the same name AND version with DIFFERENT digests — an ERROR naming both
175///   realms, because two realms then disagree about what those bytes are and
176///   varve does not pick a winner.
177///
178/// Order is preserved (root layer first), so the export is a function of the
179/// composition rather than of a map's iteration order.
180pub fn union_payloads<T>(
181    items: Vec<(PayloadOrigin, T)>,
182) -> Result<Vec<(PayloadOrigin, T)>, ComposeError> {
183    let mut first_seen: BTreeMap<(String, String), PayloadOrigin> = BTreeMap::new();
184    let mut out = Vec::new();
185    for (origin, payload) in items {
186        let key = (origin.name.clone(), origin.version.clone());
187        match first_seen.get(&key) {
188            Some(first) if first.digest != origin.digest => {
189                return Err(ComposeError::ConflictingPayload(Box::new(
190                    PayloadConflict {
191                        name: origin.name.clone(),
192                        version: origin.version.clone(),
193                        first: first.describe(),
194                        first_digest: first.digest.clone(),
195                        second: origin.describe(),
196                        second_digest: origin.digest,
197                    },
198                )));
199            }
200            // Same bytes, offered twice: export one copy, not an error.
201            Some(_) => continue,
202            None => {
203                first_seen.insert(key, origin.clone());
204                out.push((origin, payload));
205            }
206        }
207    }
208    Ok(out)
209}
210
211/// The layers a view directly composes, in manifest order.
212pub fn includes(v: &LayerView) -> Vec<Include> {
213    v.includes.clone()
214}
215
216/// Walk a composition graph breadth-first from a root manifest, refusing cycles
217/// and excessive depth. `fetch` supplies a manifest for a digest, or `None` if
218/// that layer is not installed — a missing layer is the caller's error to
219/// report (with its corrective `varve install`), not this walker's to invent.
220///
221/// Returns the visit order, root first, so callers can union tools predictably.
222pub fn walk<F>(
223    root_digest: &str,
224    root: &LayerView,
225    mut fetch: F,
226) -> Result<Vec<(String, LayerView)>, ComposeError>
227where
228    F: FnMut(&str) -> Option<LayerView>,
229{
230    let mut out = vec![(root_digest.to_string(), root.clone())];
231    let mut emitted: BTreeSet<String> = BTreeSet::new();
232    emitted.insert(root_digest.to_string());
233    // (digest, view, ancestors-on-this-path). A CYCLE is a digest reappearing
234    // on its OWN path — not merely one seen before. An earlier version used a
235    // global `seen`, which reported a DIAMOND (two layers sharing a base) as a
236    // cycle, with a message falsely claiming the layer included itself. A
237    // shared base is the most ordinary composition there is.
238    let mut stack: Vec<(String, LayerView, BTreeSet<String>)> = vec![(
239        root_digest.to_string(),
240        root.clone(),
241        BTreeSet::from([root_digest.to_string()]),
242    )];
243    while let Some((from, view, path)) = stack.pop() {
244        if path.len() > MAX_DEPTH {
245            return Err(ComposeError::TooDeep);
246        }
247        for inc in includes(&view) {
248            if path.contains(&inc.digest) {
249                return Err(ComposeError::Cycle {
250                    digest: inc.digest.clone(),
251                    via: from.clone(),
252                });
253            }
254            let Some(child) = fetch(&inc.digest) else {
255                // Not installed. The caller names it and how to fix it.
256                continue;
257            };
258            // A layer reachable by two paths is walked once, not refused.
259            if emitted.insert(inc.digest.clone()) {
260                out.push((inc.digest.clone(), child.clone()));
261            }
262            let mut child_path = path.clone();
263            child_path.insert(inc.digest.clone());
264            stack.push((inc.digest.clone(), child, child_path));
265        }
266    }
267    Ok(out)
268}
269
270/// Union the tool names a composition exposes, refusing any name that appears
271/// in more than one layer. Returns tool → the digest of the layer providing it.
272pub fn union_tools(
273    layers: &[(String, LayerView)],
274) -> Result<BTreeMap<String, String>, ComposeError> {
275    let mut owner: BTreeMap<String, String> = BTreeMap::new();
276    for (digest, v) in layers {
277        for tool in &v.tools {
278            if let Some(first) = owner.get(tool)
279                && first != digest
280            {
281                return Err(ComposeError::AmbiguousTool {
282                    tool: tool.clone(),
283                    first: first.clone(),
284                    second: digest.clone(),
285                });
286            }
287            owner.insert(tool.clone(), digest.clone());
288        }
289    }
290    Ok(owner)
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    /// A manifest with the given tools and includes.
298    fn manifest(layer: &str, tools: &[&str], includes: &[(&str, &str)]) -> LayerView {
299        let mut entries: Vec<String> = tools
300            .iter()
301            .map(|t| {
302                format!(
303                    r#"{{"digest":"sha256:{t}","annotations":{{"eu.pulseengine.tool":"{t}"}}}}"#
304                )
305            })
306            .collect();
307        for (digest, realm) in includes {
308            entries.push(format!(
309                r#"{{"digest":"{digest}","annotations":{{"eu.pulseengine.varve.kind":"layer","{ANN_INCLUDE_REALM}":"{realm}"}}}}"#
310            ));
311        }
312        let json = format!(
313            r#"{{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json",
314"artifactType":"application/vnd.pulseengine.varve.layer.v1+json",
315"annotations":{{"eu.pulseengine.varve.layer":"{layer}","eu.pulseengine.varve.channel":"qualified",
316"eu.pulseengine.varve.counter":"1","org.opencontainers.image.created":"2026-08-01T00:00:00Z"}},
317"manifests":[{}]}}"#,
318            entries.join(",")
319        );
320        let _ = layer;
321        view(json.as_bytes()).unwrap()
322    }
323
324    // rivet: verifies REQ-COMPOSE-001
325    #[test]
326    fn a_composition_exposes_both_layers_tools() {
327        let upstream = manifest("2026.08.0", &["wasm-tools", "cargo-component"], &[]);
328        let root = manifest(
329            "2026.08.0",
330            &["rivet", "meld"],
331            &[("sha256:up", "bytecodealliance")],
332        );
333        let inc = includes(&root);
334        assert_eq!(inc.len(), 1);
335        assert_eq!(inc[0].digest, "sha256:up");
336        assert_eq!(inc[0].realm.as_deref(), Some("bytecodealliance"));
337
338        let layers = walk("sha256:root", &root, |d| {
339            (d == "sha256:up").then(|| upstream.clone())
340        })
341        .unwrap();
342        assert_eq!(layers.len(), 2, "root plus the included layer");
343        let tools = union_tools(&layers).unwrap();
344        // The producing half is now answerable alongside the checking half.
345        for t in ["rivet", "meld", "wasm-tools", "cargo-component"] {
346            assert!(tools.contains_key(t), "{t} missing from the composition");
347        }
348        assert_eq!(tools["wasm-tools"], "sha256:up");
349        assert_eq!(tools["rivet"], "sha256:root");
350    }
351
352    // rivet: verifies REQ-COMPOSE-001
353    #[test]
354    fn a_tool_in_two_layers_is_an_error_not_a_silent_choice() {
355        // Both layers ship `wasm-tools`. varve must not pick one.
356        let upstream = manifest("2026.08.0", &["wasm-tools"], &[]);
357        let root = manifest(
358            "2026.08.0",
359            &["wasm-tools"],
360            &[("sha256:up", "bytecodealliance")],
361        );
362        let layers = walk("sha256:root", &root, |d| {
363            (d == "sha256:up").then(|| upstream.clone())
364        })
365        .unwrap();
366        match union_tools(&layers) {
367            Err(ComposeError::AmbiguousTool { tool, .. }) => assert_eq!(tool, "wasm-tools"),
368            other => panic!("expected AmbiguousTool, got {other:?}"),
369        }
370    }
371
372    // rivet: verifies REQ-COMPOSE-001
373    #[test]
374    fn depth_is_bounded_so_a_long_chain_cannot_exhaust_the_walker() {
375        // The bound is what keeps "refused" from meaning "followed until the
376        // process aborts". Re-verification found `verify` recursing without it
377        // and stack-overflowing on a self-referencing store entry; both walkers
378        // are now bounded.
379        let leaf = manifest("2026.08.0", &["leaf"], &[]);
380        // A chain longer than MAX_DEPTH, each link including the next.
381        let chain: Vec<LayerView> = (0..=MAX_DEPTH + 2)
382            .map(|i| manifest("2026.08.0", &["t"], &[(&format!("sha256:{}", i + 1), "r")]))
383            .collect();
384        let err = walk("sha256:0", &chain[0], |d| {
385            let n: usize = d.trim_start_matches("sha256:").parse().ok()?;
386            chain.get(n).cloned().or_else(|| Some(leaf.clone()))
387        })
388        .unwrap_err();
389        assert!(matches!(err, ComposeError::TooDeep), "got {err:?}");
390    }
391
392    // rivet: verifies REQ-COMPOSE-001
393    #[test]
394    fn a_diamond_is_walked_once_not_refused_as_a_cycle() {
395        // A includes B and C; both include D. This terminates and is the most
396        // ordinary composition shape there is — two layers sharing a base.
397        // The first version reported it as a cycle, with a message claiming D
398        // "includes itself". Found by clean-room review.
399        let d = manifest("2026.08.0", &["base"], &[]);
400        let b = manifest("2026.08.0", &["b"], &[("sha256:d", "r")]);
401        let c = manifest("2026.08.0", &["c"], &[("sha256:d", "r")]);
402        let a = manifest("2026.08.0", &["a"], &[("sha256:b", "r"), ("sha256:c", "r")]);
403        let walked = walk("sha256:a", &a, |q| match q {
404            "sha256:b" => Some(b.clone()),
405            "sha256:c" => Some(c.clone()),
406            "sha256:d" => Some(d.clone()),
407            _ => None,
408        })
409        .unwrap();
410        assert_eq!(walked.len(), 4, "A, B, C and D each once: {walked:?}");
411        // …and the shared base's tool resolves exactly once, not ambiguously.
412        let tools = union_tools(&walked).unwrap();
413        assert_eq!(tools["base"], "sha256:d");
414    }
415
416    // rivet: verifies REQ-COMPOSE-001
417    #[test]
418    fn a_cycle_is_refused_not_followed() {
419        // A includes B; B includes A. Following it would not terminate.
420        let a = manifest("2026.08.0", &["x"], &[("sha256:b", "r")]);
421        let b = manifest("2026.08.0", &["y"], &[("sha256:a", "r")]);
422        let (ac, bc) = (a.clone(), b.clone());
423        let err = walk("sha256:a", &a, move |d| match d {
424            "sha256:b" => Some(bc.clone()),
425            "sha256:a" => Some(ac.clone()),
426            _ => None,
427        })
428        .unwrap_err();
429        assert!(matches!(err, ComposeError::Cycle { .. }), "got {err:?}");
430    }
431
432    // rivet: verifies REQ-COMPOSE-001
433    #[test]
434    fn an_uninstalled_include_is_skipped_for_the_caller_to_report() {
435        // walk() does not invent a fetch. A missing layer is the caller's
436        // error to report, with its corrective `varve install`.
437        let root = manifest("2026.08.0", &["rivet"], &[("sha256:missing", "other")]);
438        let layers = walk("sha256:root", &root, |_| None).unwrap();
439        assert_eq!(layers.len(), 1, "only the root resolved");
440        assert_eq!(
441            includes(&root).len(),
442            1,
443            "but the include is still declared"
444        );
445    }
446
447    /// One payload offered by a named realm.
448    fn offered(realm: &str, name: &str, version: &str, digest: &str) -> (PayloadOrigin, ()) {
449        (
450            PayloadOrigin {
451                name: name.into(),
452                version: version.into(),
453                digest: format!("sha256:{digest}"),
454                realm: realm.into(),
455                layer: "2026.08.0".into(),
456            },
457            (),
458        )
459    }
460
461    // rivet: verifies REQ-COMPOSEEXPORT-001
462    #[test]
463    fn two_versions_of_one_crate_both_export() {
464        // Clause 2: the collision rule is NOT the tool rule. `serde 1.0.200`
465        // and `serde 1.0.210` are not ambiguous — a lockfile that names two
466        // majors of one crate NEEDS both present to build offline, and varve's
467        // own lockfile has 14 such names.
468        let kept = union_payloads(vec![
469            offered("pulseengine", "serde", "1.0.200", "aa"),
470            offered("bytecodealliance", "serde", "1.0.210", "bb"),
471        ])
472        .unwrap();
473        assert_eq!(kept.len(), 2);
474        let mut vers: Vec<&str> = kept.iter().map(|(o, _)| o.version.as_str()).collect();
475        vers.sort();
476        assert_eq!(vers, ["1.0.200", "1.0.210"]);
477    }
478
479    // rivet: verifies REQ-COMPOSEEXPORT-001
480    #[test]
481    fn the_same_name_and_version_with_the_same_bytes_exports_once() {
482        // A diamond: two layers each including the same base. Both offer the
483        // same crate at the same digest — that is two realms AGREEING, and
484        // exporting the bytes twice or refusing them both would be wrong.
485        let kept = union_payloads(vec![
486            offered("pulseengine", "cfg-if", "1.0.0", "aa"),
487            offered("bytecodealliance", "cfg-if", "1.0.0", "aa"),
488        ])
489        .unwrap();
490        assert_eq!(kept.len(), 1, "one copy of agreed bytes: {kept:?}");
491        assert_eq!(kept[0].0.realm, "pulseengine", "the first offer wins");
492    }
493
494    // rivet: verifies REQ-COMPOSEEXPORT-001
495    #[test]
496    fn the_same_name_and_version_with_different_bytes_names_both_realms() {
497        // Clause 2's error case. Two realms disagree about what `cfg-if 1.0.0`
498        // IS; picking either would put bytes one realm never vouched for into
499        // an export the consumer believes is verified. The message must name
500        // BOTH realms, or the reader cannot tell which side to fix.
501        let err = union_payloads(vec![
502            offered("pulseengine", "cfg-if", "1.0.0", "aa"),
503            offered("bytecodealliance", "cfg-if", "1.0.0", "bb"),
504        ])
505        .unwrap_err();
506        let msg = err.to_string();
507        assert!(matches!(err, ComposeError::ConflictingPayload(_)), "{msg}");
508        assert!(msg.contains("cfg-if") && msg.contains("1.0.0"), "{msg}");
509        assert!(
510            msg.contains("pulseengine") && msg.contains("bytecodealliance"),
511            "both realms must be named: {msg}"
512        );
513        assert!(
514            msg.contains("sha256:aa") && msg.contains("sha256:bb"),
515            "both digests must be named: {msg}"
516        );
517    }
518
519    // rivet: verifies REQ-COMPOSE-001
520    #[test]
521    fn a_layer_without_includes_composes_to_itself() {
522        // Back-compat: every existing layer has no `layer` entries and must
523        // behave exactly as before.
524        let plain = manifest("2026.08.0", &["rivet", "meld"], &[]);
525        assert!(includes(&plain).is_empty());
526        let layers = walk("sha256:root", &plain, |_| None).unwrap();
527        assert_eq!(layers.len(), 1);
528        assert_eq!(union_tools(&layers).unwrap().len(), 2);
529    }
530}