1use std::collections::{BTreeMap, BTreeSet};
21
22#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub struct LayerView {
30 pub includes: Vec<Include>,
31 pub tools: Vec<String>,
33}
34
35pub 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 None => {
56 if let Some(t) = ann["eu.pulseengine.tool"].as_str() {
57 v.tools.push(t.to_string());
58 }
59 }
60 Some(_) => {}
62 }
63 }
64 Ok(v)
65}
66
67pub const ANN_INCLUDE_REALM: &str = "eu.pulseengine.varve.include.realm";
70pub const ANN_INCLUDE_LAYER: &str = "eu.pulseengine.varve.include.layer";
73
74pub const MAX_DEPTH: usize = 8;
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Include {
82 pub digest: String,
84 pub realm: Option<String>,
86 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}
115
116pub fn includes(v: &LayerView) -> Vec<Include> {
118 v.includes.clone()
119}
120
121pub fn walk<F>(
128 root_digest: &str,
129 root: &LayerView,
130 mut fetch: F,
131) -> Result<Vec<(String, LayerView)>, ComposeError>
132where
133 F: FnMut(&str) -> Option<LayerView>,
134{
135 let mut out = vec![(root_digest.to_string(), root.clone())];
136 let mut emitted: BTreeSet<String> = BTreeSet::new();
137 emitted.insert(root_digest.to_string());
138 let mut stack: Vec<(String, LayerView, BTreeSet<String>)> = vec![(
144 root_digest.to_string(),
145 root.clone(),
146 BTreeSet::from([root_digest.to_string()]),
147 )];
148 while let Some((from, view, path)) = stack.pop() {
149 if path.len() > MAX_DEPTH {
150 return Err(ComposeError::TooDeep);
151 }
152 for inc in includes(&view) {
153 if path.contains(&inc.digest) {
154 return Err(ComposeError::Cycle {
155 digest: inc.digest.clone(),
156 via: from.clone(),
157 });
158 }
159 let Some(child) = fetch(&inc.digest) else {
160 continue;
162 };
163 if emitted.insert(inc.digest.clone()) {
165 out.push((inc.digest.clone(), child.clone()));
166 }
167 let mut child_path = path.clone();
168 child_path.insert(inc.digest.clone());
169 stack.push((inc.digest.clone(), child, child_path));
170 }
171 }
172 Ok(out)
173}
174
175pub fn union_tools(
178 layers: &[(String, LayerView)],
179) -> Result<BTreeMap<String, String>, ComposeError> {
180 let mut owner: BTreeMap<String, String> = BTreeMap::new();
181 for (digest, v) in layers {
182 for tool in &v.tools {
183 if let Some(first) = owner.get(tool)
184 && first != digest
185 {
186 return Err(ComposeError::AmbiguousTool {
187 tool: tool.clone(),
188 first: first.clone(),
189 second: digest.clone(),
190 });
191 }
192 owner.insert(tool.clone(), digest.clone());
193 }
194 }
195 Ok(owner)
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 fn manifest(layer: &str, tools: &[&str], includes: &[(&str, &str)]) -> LayerView {
204 let mut entries: Vec<String> = tools
205 .iter()
206 .map(|t| {
207 format!(
208 r#"{{"digest":"sha256:{t}","annotations":{{"eu.pulseengine.tool":"{t}"}}}}"#
209 )
210 })
211 .collect();
212 for (digest, realm) in includes {
213 entries.push(format!(
214 r#"{{"digest":"{digest}","annotations":{{"eu.pulseengine.varve.kind":"layer","{ANN_INCLUDE_REALM}":"{realm}"}}}}"#
215 ));
216 }
217 let json = format!(
218 r#"{{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json",
219"artifactType":"application/vnd.pulseengine.varve.layer.v1+json",
220"annotations":{{"eu.pulseengine.varve.layer":"{layer}","eu.pulseengine.varve.channel":"qualified",
221"eu.pulseengine.varve.counter":"1","org.opencontainers.image.created":"2026-08-01T00:00:00Z"}},
222"manifests":[{}]}}"#,
223 entries.join(",")
224 );
225 let _ = layer;
226 view(json.as_bytes()).unwrap()
227 }
228
229 #[test]
231 fn a_composition_exposes_both_layers_tools() {
232 let upstream = manifest("2026.08.0", &["wasm-tools", "cargo-component"], &[]);
233 let root = manifest(
234 "2026.08.0",
235 &["rivet", "meld"],
236 &[("sha256:up", "bytecodealliance")],
237 );
238 let inc = includes(&root);
239 assert_eq!(inc.len(), 1);
240 assert_eq!(inc[0].digest, "sha256:up");
241 assert_eq!(inc[0].realm.as_deref(), Some("bytecodealliance"));
242
243 let layers = walk("sha256:root", &root, |d| {
244 (d == "sha256:up").then(|| upstream.clone())
245 })
246 .unwrap();
247 assert_eq!(layers.len(), 2, "root plus the included layer");
248 let tools = union_tools(&layers).unwrap();
249 for t in ["rivet", "meld", "wasm-tools", "cargo-component"] {
251 assert!(tools.contains_key(t), "{t} missing from the composition");
252 }
253 assert_eq!(tools["wasm-tools"], "sha256:up");
254 assert_eq!(tools["rivet"], "sha256:root");
255 }
256
257 #[test]
259 fn a_tool_in_two_layers_is_an_error_not_a_silent_choice() {
260 let upstream = manifest("2026.08.0", &["wasm-tools"], &[]);
262 let root = manifest(
263 "2026.08.0",
264 &["wasm-tools"],
265 &[("sha256:up", "bytecodealliance")],
266 );
267 let layers = walk("sha256:root", &root, |d| {
268 (d == "sha256:up").then(|| upstream.clone())
269 })
270 .unwrap();
271 match union_tools(&layers) {
272 Err(ComposeError::AmbiguousTool { tool, .. }) => assert_eq!(tool, "wasm-tools"),
273 other => panic!("expected AmbiguousTool, got {other:?}"),
274 }
275 }
276
277 #[test]
279 fn depth_is_bounded_so_a_long_chain_cannot_exhaust_the_walker() {
280 let leaf = manifest("2026.08.0", &["leaf"], &[]);
285 let chain: Vec<LayerView> = (0..=MAX_DEPTH + 2)
287 .map(|i| manifest("2026.08.0", &["t"], &[(&format!("sha256:{}", i + 1), "r")]))
288 .collect();
289 let err = walk("sha256:0", &chain[0], |d| {
290 let n: usize = d.trim_start_matches("sha256:").parse().ok()?;
291 chain.get(n).cloned().or_else(|| Some(leaf.clone()))
292 })
293 .unwrap_err();
294 assert!(matches!(err, ComposeError::TooDeep), "got {err:?}");
295 }
296
297 #[test]
299 fn a_diamond_is_walked_once_not_refused_as_a_cycle() {
300 let d = manifest("2026.08.0", &["base"], &[]);
305 let b = manifest("2026.08.0", &["b"], &[("sha256:d", "r")]);
306 let c = manifest("2026.08.0", &["c"], &[("sha256:d", "r")]);
307 let a = manifest("2026.08.0", &["a"], &[("sha256:b", "r"), ("sha256:c", "r")]);
308 let walked = walk("sha256:a", &a, |q| match q {
309 "sha256:b" => Some(b.clone()),
310 "sha256:c" => Some(c.clone()),
311 "sha256:d" => Some(d.clone()),
312 _ => None,
313 })
314 .unwrap();
315 assert_eq!(walked.len(), 4, "A, B, C and D each once: {walked:?}");
316 let tools = union_tools(&walked).unwrap();
318 assert_eq!(tools["base"], "sha256:d");
319 }
320
321 #[test]
323 fn a_cycle_is_refused_not_followed() {
324 let a = manifest("2026.08.0", &["x"], &[("sha256:b", "r")]);
326 let b = manifest("2026.08.0", &["y"], &[("sha256:a", "r")]);
327 let (ac, bc) = (a.clone(), b.clone());
328 let err = walk("sha256:a", &a, move |d| match d {
329 "sha256:b" => Some(bc.clone()),
330 "sha256:a" => Some(ac.clone()),
331 _ => None,
332 })
333 .unwrap_err();
334 assert!(matches!(err, ComposeError::Cycle { .. }), "got {err:?}");
335 }
336
337 #[test]
339 fn an_uninstalled_include_is_skipped_for_the_caller_to_report() {
340 let root = manifest("2026.08.0", &["rivet"], &[("sha256:missing", "other")]);
343 let layers = walk("sha256:root", &root, |_| None).unwrap();
344 assert_eq!(layers.len(), 1, "only the root resolved");
345 assert_eq!(
346 includes(&root).len(),
347 1,
348 "but the include is still declared"
349 );
350 }
351
352 #[test]
354 fn a_layer_without_includes_composes_to_itself() {
355 let plain = manifest("2026.08.0", &["rivet", "meld"], &[]);
358 assert!(includes(&plain).is_empty());
359 let layers = walk("sha256:root", &plain, |_| None).unwrap();
360 assert_eq!(layers.len(), 1);
361 assert_eq!(union_tools(&layers).unwrap().len(), 2);
362 }
363}